diff --git a/CHANGELOG.md b/CHANGELOG.md index b7cb750..893c72d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Не выпущено +- Deployment: подготовлен внешний handoff для Windows-клиента от точного Gitea + commit. Bundle фиксирует commit/tree/blob identity и SHA-256; installer + проверяет bundle и ACL до записи и не запускает Git. Bootstrap на release A + может подготовить release B только при точном совпадении pinned runtime-файлов + и release policy. Первый Windows task остаётся preparation-only; отдельные + auto-apply config и task выключены. Изолированный PowerShell 5.1 harness для + bundle-проверки завершился успешно на точном PR SHA; реальная установка, + регистрация задач, SSH и production не выполнялись и не активировались. - Документация: объединены продуктовый, UI- и deployment-планы; уточнены внешняя backup-retention политика и обязательный owner-approved maintenance window для cold backup. Передача корпуса требует атомарной публикации diff --git a/docs/operations/CURRENT_STATE.md b/docs/operations/CURRENT_STATE.md index 9e946e3..5bcc87e 100644 --- a/docs/operations/CURRENT_STATE.md +++ b/docs/operations/CURRENT_STATE.md @@ -1,28 +1,57 @@ # Infrastructure current state -## Proposed external deployment runtime binding — 2026-11-01 +## External deployment runtime binding — 2026-11-03 -- PR #59 now proposes a root-only read-only status endpoint and exact-literal - SSH dispatcher for `status` and `deploy`. The status path bounds the Gitea - run list to ten entries, verifies the selected run/workflow/current `main`, +- PR #59 is merged to `main`; it contains the root-only read-only status + endpoint and exact-literal SSH dispatcher for `status` and `deploy`. The + status path bounds the Gitea run list to ten entries, verifies the selected + run/workflow/current `main`, validates artifact metadata and ZIP bytes, and returns only a release tuple. - It fails closed for a stale host checkout, recovery journal, incomplete - listing, or the exact release already recorded as applied. The deploy - receiver still independently revalidates and uses the existing lock, - recovery journal, cold-backup ACK, cutover, and rollback protections. -- The Windows client proposes a disabled-by-default `--once` mode with a - fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example - config and Task Scheduler XML are both disabled; the template uses an - interactive least-privilege owner, ignores overlapping invocations, and - does not wake the computer. No Windows task, SSH key, VPS command, helper, - or trigger config has been installed or exercised. Production CD remains - inactive. -- Remaining owner prerequisites are exact root forced-command/SSH host-key - setup for the dispatcher, installation of the reviewed Windows client under - a non-user-writable path, private ACLs for its config and D: backups, a - root-controlled checkout matching current `main`, and all previously listed - backup/restore and production readiness gates. The status endpoint does not - advance the host checkout and refuses to offer a stale release. + This unmerged change adds a two-SHA compatibility gate: the fixed root helper + compares protected Compose/Caddy bytes and installed release policy with + Gitea content pinned to the release SHA, verifying each Git blob hash and + size. This permits application-only release B after bootstrap A without + advancing the root checkout or executing new root code. A runtime config or + policy change fails closed and requires a reviewed bootstrap update. The + Windows response state is `prepared`, never `candidate`; the `--once` task + stores a protected local tuple and cannot call deploy. A separate disabled + auto-apply task and config gate require the tuple to match a fresh status + response from a previous poll. Status and apply retain current-main, + successful CI/release provenance, descriptor digest, external backup, + cutover, canary, and rollback checks. The receiver repeats the config check + before journaling/stopping services and again after backup immediately before + switching refs. +- Windows client предназначен для подготовки и auto-apply; обе задачи по умолчанию + выключены, целевой путь фиксирован: `D:\Dev\akyldash`, SSH alias — + `akyldash_release`. Примеры config и Task Scheduler XML также выключены; задачи + запускаются от интерактивного пользователя с минимальными правами, не допускают + параллельных запусков и не будят компьютер. Windows task, SSH key, VPS command, + helper и trigger не устанавливались и не запускались. Путь canonical + `C:\ProgramData\Akyldash\release-trigger.json` известен, но содержимое и + enabled-состояние не читались. Метаданные Windows подтверждают + `C:\Python313\python.exe`, действительную Authenticode-подпись Python Software + Foundation и защищённые ACL интерпретатора. +- Linux fixtures прошли для Python import и command-path poisoning, включая очистку + унаследованных `GIT_*` и фиксацию путей `ssh.exe`/`git.exe`/`powershell.exe`. + Installer получает ограниченный bundle из точной вершины `main`: упаковщик + читает Git objects commit, tree и allowlist-файлов; elevated installer проверяет + SHA-256 ZIP и цепочку `commit → tree → файлы` из одного read-locked потока до + записи и не запускает Git. ACL проверяются явными масками мутаций и подмены; + текущая учётная запись не может владеть кодовыми путями, а право создать + соседнюю папку не считается подменой предка. Windows ACL/install проверки пока + только прошли код-ревью; installer и launcher на Windows не запускались. + Production CD остаётся выключенным. +- Remaining owner prerequisites are installation of the reviewed root helper, + exact forced-command/SSH host-key setup, a protected Windows client and its + private state/config ACLs, private D: backup ACLs, and all previously listed + backup/restore and production readiness gates. Neither the status endpoint nor client advances + the host checkout or updates root helper code. + +- Read-only Gitea check for fresh `main` SHA + `f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded; release + run #708 failed at the explicit registry credential-presence check because + `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are absent; handoff run #709 was + skipped. No secret values were read. Image publication remains owner-blocked. Owner decision recorded 2026-10-26: a complete backup must not be stored on the VPS, including temporarily. The local archive/full cold-backup commands in diff --git a/docs/operations/DEPLOYMENT.md b/docs/operations/DEPLOYMENT.md index 7930f17..cc02ff7 100644 --- a/docs/operations/DEPLOYMENT.md +++ b/docs/operations/DEPLOYMENT.md @@ -2,10 +2,12 @@ ## Status and authorization -The implementation in PR #59 is not merged, installed, or exercised against -the VPS. No release, backup, restore, or production service operation has been -performed by this change. Production deployment remains blocked until the -owner has verified host state, registry access, protected configuration, +The release and fixed-receiver code from PR #59 is on `main`, but has not been +installed or exercised against the VPS. This PR's compatibility and Windows +bootstrap changes are still proposed. No release, backup, restore, or +production service operation has been performed by this change. Production +deployment remains blocked until the owner has verified host state, registry +access, protected configuration, corpus/index readiness, RU/KY canaries, an external backup, and restore on a separate target. See [current state](CURRENT_STATE.md) and the [owner evidence packet](DEPLOYMENT_OWNER_PACKET.md). @@ -26,6 +28,13 @@ The descriptor records both the release workflow run ID and the successful CI source run ID. PR builds do not publish images. Production runtime secrets are not supplied to CI. +Read-only Gitea check on 2026-11-03 for current `main` SHA +`f19faf628e67015febac22d0ec93ce4782caf4bf`: CI run #706 succeeded, release +run #708 failed at the explicit registry-credential-presence check because +`REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are not configured, and handoff run +#709 was skipped. No secret values were read. Image publication therefore +remains blocked until the owner provisions the required registry secrets. + On the host, the fixed root receiver uses the installed `gitea-api` helper to re-fetch and verify the exact successful CI and release runs, current `main`, artifact name, checksum, manifest schema, builder pin, image policy, and commit @@ -35,46 +44,103 @@ the response, and refuses to overwrite an existing file. The installer checks the exact audited helper fixture hash before applying its patch. The helper and source code are not installed on the VPS by this PR. -## One-shot runtime binding proposed by PR #59 +## One-shot runtime binding and bootstrap compatibility -PR #59 adds a root-only, read-only status command and a fixed SSH dispatcher. +`main` includes a root-only, read-only status command and a fixed SSH dispatcher. The dispatcher accepts only the literal status or deploy command in `SSH_ORIGINAL_COMMAND`; it accepts no arguments and performs no shell -evaluation. Status fails closed on a stale root checkout, recovery journal, -incomplete Gitea run listing, failed source/workflow checks, invalid artifact -metadata/ZIP, or a release already applied. The candidate response contains -only a run ID, commit, and descriptor digest. The root receiver independently -repeats release verification and the existing backup/ACK/cutover guards. +evaluation. Status verifies successful current-main provenance, artifact +metadata/ZIP, and host runtime compatibility before returning only a run ID, +commit, and descriptor digest in a `prepared` response. It fails closed on a +recovery journal, incomplete Gitea run listing, invalid release data, changed +host runtime configuration, or an already applied release. The root receiver +independently repeats release verification and the existing backup/ACK/cutover +guards. -The Windows client adds a one-shot `receive_external.py --once` mode. Its -configuration is fixed to `D:\Dev\akyldash` and the `akyldash_release` SSH -alias, and is disabled by default. It uses BatchMode, disables forwarding, -uses the existing host-key checking, bounds status output and time, then calls -the existing `run_release` path. Rollback remains a separate explicit -owner-run command. The example Task Scheduler XML is disabled, runs as the -interactive owner at least privilege, ignores overlapping instances, and -never wakes the computer. No task has been registered or enabled by this PR. +The Windows `--once` mode only polls the fixed read-only status endpoint and +stores a protected local `PREPARED` tuple. It never sends a deploy request. +Automatic deployment uses a second task and a separate +`release-auto-apply.json` gate; both config files and both scheduler XML +templates default to disabled. The apply task acts only when its current +verified tuple exactly matches a tuple saved by a previous poll. An owner can +also use `--apply-prepared`, which requires an interactive exact-SHA +confirmation. Both deploy paths re-enter the existing root receiver and all +backup/ACK/cutover guards. Rollback remains a separate explicit owner-run +командой. Планировщик запускает задачи от интерактивного владельца с минимальными +правами, пропускает повторный запуск при активном экземпляре и не будит компьютер. +Этот PR не зарегистрировал и не включил ни одну задачу. -Before any activation, the owner must install the reviewed receiver and its -`archive.py` and `external_session.py` dependencies under a non-user-writable -`C:\Program Files\Akyldash` directory, set the config ACL so -only the owner, SYSTEM, and Administrators have full control, fill in the real -Python executable, owner SID, and local start time in the task template, and -confirm SSH host-key checking and the forced-command alias. Keep `enabled` -false and the task -disabled until the normal backup/restore and production readiness gates are -recorded. Do not bind a Gitea workflow or PR runner to this Windows host. +Сначала подготовьте bundle под обычной учётной записью из точной локальной +вершины `main` командой `prepare_windows_release_bundle.py`. Упаковщик читает +только Git objects указанного полного commit SHA, сверяет Git blob identity +исходников и создаёт небольшой ZIP с SHA-256; рабочее дерево он не читает и не +копирует. Elevated installer получает путь к ZIP, ожидаемый commit и SHA-256. +Он один раз открывает пакет с запретом записи/удаления, проверяет SHA-256 архива, +manifest и Git blob IDs в памяти, затем копирует только проверенные байты. +Дополнительно installer проверяет цепочку Git objects `commit → tree → файлы`. +На elevated этапе Git не запускается; `.git/config`, hooks и состояние checkout +не используются. Не изменяйте ZIP между подготовкой и установкой. +Упаковщик ставит всем ZIP entries тип regular file и mode `0100600`; installer +проверяет тот же mode (`0x8180`) до чтения содержимого. Для проверки стыка +упаковщика и фактической функции mode-валидации на Windows используйте +`powershell.exe -NoProfile -File infra/deploy/test_windows_release_bundle_mode.ps1 -PythonPath C:\Python313\python.exe`; +`git.exe` должен уже разрешаться обычным `PATH`. Harness создаёт синтетический +Git репозиторий и временный ZIP, извлекает только эту функцию из PowerShell AST и +проверяет принятие штатного пакета и отказ после изменения mode. Он не запускает +installer, не требует elevation, не меняет ACL и не регистрирует задачи; политику +выполнения PowerShell менять для него не следует. -The root-side status endpoint requires `/opt/akyldash/current` to exactly -match current `main`; it does not update that checkout. An owner must advance -the root-controlled checkout through the approved installation process before -a release built from a newer commit can be considered ready. Gitea's filtered -workflow-run listing is bounded to ten entries; if Gitea reports more entries -or an inconsistent page, status returns unavailable rather than guessing. +Проверенный bootstrap installer проверяет подпись PSF у +`C:\Python313\python.exe` и защищённые ACL, затем размещает файлы клиента в +SHA-версионном каталоге `Program Files`, не перезаписывая существующую версию. +Для новых файлов кода владельцем назначается `Administrators`. У кода владельцем +могут быть только SYSTEM, Administrators или TrustedInstaller; учётная запись +задачи допускается владельцем только для отдельного private state/config. +Проверка ACL файла и защищённого каталога запрещает недоверенным субъектам запись, +добавление, удаление, удаление дочерних объектов, смену прав или владельца. Для +родительских каталогов проверяются права, способные подменить защищённый путь; +разрешение создать посторонний соседний каталог подменой не считается. +Installer сохраняет существующий canonical-файл +`C:\ProgramData\Akyldash\release-trigger.json`, только если он выключен; второй +disabled auto-apply config создаётся только при отсутствии. Installer формирует +два disabled task XML для проверки владельцем, но не регистрирует и не включает +задачи. Launcher запускает Python с `-B -E -s -S`, оставляет в import path только +стандартную библиотеку и защищённый каталог клиента, затем проверяет происхождение +импортированных модулей. До импорта receiver он заменяет унаследованный command +lookup фиксированными каталогами Windows, OpenSSH и Git, очищает `GIT_*` и +проверяет, что `ssh.exe`, `git.exe` и `powershell.exe` разрешаются в заданные пути. +Для самих бинарников и их родительских каталогов проверяются reparse points, +владельцы и ACL по правилам выше. Тесты подмены Python imports и command paths +выполнены на Linux; installer и launcher на Windows не запускались. Оставляйте оба +config gate и обе задачи выключенными до фиксации владельцем обычных backup/restore +и production readiness gate. Не привязывайте Windows-хост к Gitea workflow или PR runner. + +The root status/deploy helper remains the reviewed root-installed bootstrap +code; it never checks out or executes target release code. A newer app release +is eligible without manually advancing `/opt/akyldash/current` only when the +host's Compose file, Caddyfile, and installed release policy match the exact +bytes at the prepared commit in Gitea. File metadata is pinned to that commit; +the Git blob SHA and size are verified. Status checks before preparing the +tuple; the receiver repeats the check before creating a journal or stopping +services, and cutover checks again after the verified external backup and +immediately before switching image references. A Compose/Caddy mismatch +returns `runtime_config_changed`; a policy mismatch returns +`release_policy_changed` and requires a reviewed owner bootstrap update. No +host checkout or root helper is automatically changed. The release must still +be current `main`; CI/release provenance, digest, backup, canary, and rollback +checks remain required. Gitea's filtered workflow-run listing is bounded to +ten entries; if Gitea reports more entries or an inconsistent page, status +returns unavailable rather than guessing. ## Automation boundary: deployment is not active -The automatic chain currently ends at release publication: +Until the owner separately activates both Windows tasks and the auto-apply +configuration after the backup/restore and production gates, the chain ends at +release publication. After that explicit one-time owner authorization, the +prepared-state poll and separate apply task can process later matching +application releases without advancing `/opt/akyldash/current` or approving +each commit individually. A release that changes protected runtime files still +requires a reviewed bootstrap update. 1. Successful `main` CI triggers image builds and publishes digest references plus the release descriptor artifact. @@ -82,14 +148,15 @@ The automatic chain currently ends at release publication: Its job is explicitly status-only: **NOT DEPLOYED**. It does not download the descriptor ZIP, call the Windows client, open SSH, or invoke the root receiver. A green result means only that this status check completed. -3. The fixed receiver is physically started only by an explicitly enabled - owner-controlled Windows trigger or by the manual release CLI. The proposed - `--once` path queries root's verified status endpoint and receives the - fixed tuple; no repository workflow invokes it. The example scheduler and - config remain disabled, and no Windows trigger has been installed or - exercised. +3. The fixed receiver is physically started only by the separately enabled + owner-controlled auto-apply task or by an interactive owner command. The + `--once` preparation task stores a verified tuple and never sends deploy; + the separate apply task requires the tuple to match a fresh status response. + No repository workflow invokes either task. Both scheduler templates and + both configuration gates remain disabled, and neither task has been + installed or exercised. -Production CD is still not active: no Windows task or credential is installed, +Production CD is still not active: task/credential setup has not been verified, and no VPS or live D: operation has been performed. This design avoids a Windows Actions runner entirely; the owner-controlled local task is not exposed to PR jobs. The SSH account/key must be restricted to the exact dispatcher @@ -150,9 +217,10 @@ from a root-owned checkout and provision, without exposing values in logs: exact `SSH_ORIGINAL_COMMAND` matching and grant no shell, forwarding, PTY, or arbitrary command access; - a protected installation of the Windows client under - `C:\Program Files\Akyldash`, with the sample JSON and Task Scheduler XML - reviewed and kept disabled until the owner explicitly accepts unattended - polling. + `C:\Program Files\Akyldash`, with both config files and both Task Scheduler + XML templates reviewed. Keep the preparation task/config separate from the + auto-apply task/config; the auto-apply pair remains disabled until the owner + explicitly accepts unattended production deployment. The installer intentionally does not create credentials, SSH entries, canary values, registry permissions, or a new privilege rule. Existing files with diff --git a/docs/operations/DEPLOYMENT_OWNER_PACKET.md b/docs/operations/DEPLOYMENT_OWNER_PACKET.md index 8dff4fc..8a8b85f 100644 --- a/docs/operations/DEPLOYMENT_OWNER_PACKET.md +++ b/docs/operations/DEPLOYMENT_OWNER_PACKET.md @@ -1,91 +1,125 @@ -# Deployment owner evidence packet +# Пакет проверок владельца перед деплоем -This packet is a list of owner-only checks. It does not authorize a production -change. Do not include token/password values, environment-file contents, -private keys, secret material, or raw process environments. +Здесь перечислены проверки, которые выполняет владелец. Этот документ не +разрешает production-изменения. Не включайте значения токенов и паролей, +содержимое env-файлов, приватные ключи, другие секреты или полный вывод +переменных окружения. -## Required before release use +## Что требуется до использования CD -- PR #59 proposes a disabled one-shot Windows trigger. No task or endpoint is - installed. If the owner chooses to activate it after merge and all other - gates, install the reviewed `receive_external.py`, `archive.py`, and - `external_session.py` client files in `C:\Program Files\Akyldash` with - write access limited to administrators/SYSTEM, copy - `infra/deploy/windows-release-trigger.example.json` to - `C:\ProgramData\Akyldash\release-trigger.json` and retain - `enabled:false`, then grant only the interactive owner, SYSTEM, and - Administrators full control on the config and its parent. Fill in the real - Python path, owner SID, and a future local start time in the XML template. - Review/import the task while - disabled; it runs as that logged-on owner at least privilege, ignores a - concurrent instance, does not wake the PC, and invokes only `--once`. Enable - the JSON and task only after the owner separately approves unattended - polling. Disable either one to stop future polls; an already running cold - backup/deploy must be allowed to finish or be explicitly recovered. -- The `akyldash_release` SSH key must be dedicated to the root forced-command - dispatcher, with OpenSSH `restrict` (or equivalent no-PTY/no-forwarding - options), verified host-key checking, and exact status/deploy command - matching. Do not put an unrestricted command or shell in root's - `authorized_keys`. The existing rollback key/path remains a separate manual - owner action. No key is generated, copied, or installed by this PR. -- Decide whether unattended production CD is acceptable. It currently is not - active: `deploy-handoff.yaml` remains status-only and reports `NOT DEPLOYED`; - the proposed local task and its config remain disabled. This avoids exposing - the Windows host to repository PR workflows. Verify that the root status - command is installed from a reviewed checkout, its forced-command key is - restricted, and the host checkout exactly matches current `main`. It does - not update that checkout automatically. The endpoint fails closed if the - filtered workflow-run API page is incomplete (more than ten candidates or - count mismatch), so status API behavior must be checked on the live Gitea - before activation. -- Confirm the registry package names and least-privilege writer/pull access for - `admin/akyldash/backend` and `admin/akyldash/opensearch`; record digest - retention for the rollback window. -- Confirm Gitea artifact retention covers the rollback window. The workflow - requests 90 days for the small release descriptor and checksum. -- Provide a dated, sanitized root audit: installed checkout SHA, Compose - services and image IDs, health state, volume paths/modes, protected-file - owner/mode, listener summary, and current recovery-journal state. -- Confirm a root-controlled SSH path exists for the fixed deploy and rollback - commands, using existing owner SSH configuration and verified host-key - checking. The repository `codex` account must not receive general sudo, - Docker-group membership, or a Docker socket. No SSH key or root alias is - provisioned by this PR. -- If required, install a narrowly scoped, read-only Gitea API token at - `/etc/akyldash/gitea-api-token` with root ownership and mode `0600`. Record - only whether it is present and its scope; never send its value. -- Verify the Gitea artifact download origin. If redirected, provision only the - exact HTTPS origin in `/etc/akyldash/gitea-download-origins` as a root-owned - mode-`0600` file. The patched helper must not send its token to that origin. -- Provide owner-approved RU and KY search canary queries in - `/etc/akyldash/release-canaries.json`, root-owned mode `0600`. Confirm they - return expected non-empty results without sending corpus documents. -- Confirm Cloudflare TLS mode, origin certificate renewal, and current public - `/review` and search health. +- Отключённый одноразовый Windows trigger из PR #59 уже находится в `main`, но + не установлен и не запускался. Bootstrap из этого PR также не установлен. + После ревью и слияния обновите локальный `main` до проверенного полного SHA. + Под обычным пользователем подготовьте bundle командой + `prepare_windows_release_bundle.py --repository D:\Dev\akyldash --commit --output `. + Упаковщик требует, чтобы SHA совпадал с локальной вершиной `main`; он читает + Git objects этого commit, а не рабочее дерево, проверяет SHA-1 объектов + commit/tree/blob и печатает SHA-256 ZIP. Передайте именно эти SHA и путь + elevated installer: + `install_windows_release_client.ps1 -ExpectedCommit -BundlePath -ExpectedBundleSha256 `. + Installer открывает ZIP один раз с запретом записи и удаления, сверяет SHA-256, + manifest и цепочку `commit → tree → файлы` в памяти и только после полной + проверки копирует эти байты. Elevated installer не запускает Git и не читает + `.git/config`, hooks или файлы checkout. Не меняйте ZIP между подготовкой и + установкой; сохраните SHA и полный commit для журнала владельца. +- Installer проверяет подпись PSF у `C:\Python313\python.exe` и защищённые + ACL, создаёт версионный каталог в `Program Files` без перезаписи существующей + версии и назначает владельцем новых файлов кода `Administrators`. Кодовые + пути должны принадлежать только SYSTEM, Administrators или TrustedInstaller; + учётная запись задачи допускается владельцем только для отдельных private + state/config. ACL-проверка файла/кодового каталога выявляет запись, добавление, + удаление, удаление дочерних объектов, смену ACL и владельца. Проверка родительских + каталогов выявляет права, позволяющие заменить защищённый путь; создание + постороннего соседнего каталога само по себе не считается подменой. +- Installer сохраняет существующий `C:\ProgramData\Akyldash\release-trigger.json` + только если он остаётся отключённым, создаёт disabled auto-apply config лишь + при отсутствии и формирует два disabled Task Scheduler XML для проверки. + Он не регистрирует и не включает задачи. Путь trigger известен, но содержимое + и его enabled-состояние не читались. +- Launcher использует проверенный `C:\Python313\python.exe` с `-B -E -s -S`, + проверяет происхождение импортированных модулей, заменяет унаследованный `PATH`, + очищает `GIT_*` и фиксирует разрешение `ssh.exe`, `git.exe` и `powershell.exe` + в доверенные пути. Задача подготовки сохраняет защищённый `PREPARED` tuple и + никогда не отправляет deploy-запрос. +- Задача auto-apply и её отдельный конфиг тоже остаются выключенными. Одного + `enabled` в preparation config недостаточно для деплоя. Auto-apply разрешён, + только если свежий root status точно совпал с tuple, сохранённым предыдущим + опросом. После явного согласования владельцем unattended deployment отдельное + подтверждение каждого SHA не требуется. Включайте preparation config и задачу + только после отдельного разрешения на опрос; auto-apply config и задачу — только + после отдельного разрешения на unattended production deployment и выполнения + всех backup/restore gate. Отключение любого auto-apply control останавливает + будущие автодеплои; уже начатый cold backup/deploy должен завершиться либо быть + восстановлен по процедуре. +- Ключ `akyldash_release` должен быть отдельным и привязанным к root forced-command + dispatcher с OpenSSH `restrict` (или эквивалентным запретом PTY и forwarding), + проверкой host key и точным сопоставлением status/deploy-команд. Не добавляйте + unrestricted shell или команду в root `authorized_keys`. Существующий ключ и + путь rollback остаются отдельным ручным действием владельца. PR не создаёт, + не копирует и не устанавливает ключи. +- Решите, допустим ли unattended production CD. Сейчас он не включён: + `deploy-handoff.yaml` остаётся только status-проверкой и сообщает + `NOT DEPLOYED`; обе локальные задачи и оба config gate выключены. Windows-хост + не открыт workflow и PR runner. Убедитесь, что root status command установлен + из проверенного bootstrap и ключ forced command ограничен. +- Checkout на хосте не обязан совпадать с текущим `main`: status сравнивает + защищённые root Compose/Caddy файлы и установленную release policy с файлами + точного candidate SHA через pinned Gitea Contents API и проверяет Git blob IDs + и размеры. Receiver повторяет сравнение до остановки сервисов; cutover повторяет + его после backup. Несовпадение требует отдельного проверенного owner bootstrap + update; код release или root helper автоматически не выполняется и не обновляется. + Текущий `main` и CI/release provenance всё равно перепроверяются перед apply. + Endpoint закрывается при неполной странице workflow-run API (более десяти + кандидатов или несовпадение count). Перед включением проверьте поведение этого + API на установленной версии Gitea. +- Подтвердите имена registry packages и минимальные права записи/чтения для + `admin/akyldash/backend` и `admin/akyldash/opensearch`; сохраните digest не + короче rollback window. Последняя read-only проверка Gitea: main CI run #706 + завершился успешно, image release run #708 завершился ошибкой из-за отсутствия + repository secrets `REGISTRY_USERNAME` и `REGISTRY_PASSWORD`, status-only + handoff run #709 был пропущен. Добавьте эти secrets через настройки Gitea; + не помещайте значения в Git или логи. +- Проверьте, что срок хранения Gitea artifacts покрывает rollback window. + Workflow запрашивает хранение небольшого release descriptor и checksum на 90 дней. +- Подготовьте датированный очищенный root audit: SHA установленного checkout, + Compose services и image IDs, состояние health, пути и режимы volumes, + владельца/режим защищённых файлов, сводку listeners и состояние recovery journal. +- Подтвердите root-controlled SSH путь для фиксированных deploy/rollback-команд, + используя существующую owner SSH-конфигурацию и проверенный host key. Репозиторный + `codex` не должен получать общий sudo, членство в Docker group или Docker socket. + PR не устанавливает SSH key или root alias. +- Если потребуется, установите узко ограниченный read-only Gitea API token в + `/etc/akyldash/gitea-api-token`, владелец root, mode `0600`. Запишите только + наличие и scope токена, не его значение. +- Проверьте origin скачивания Gitea artifacts. При redirect добавляйте только + точный HTTPS origin в `/etc/akyldash/gitea-download-origins`, root-owned, + mode `0600`. Исправленный helper не должен отправлять этому origin token. +- Добавьте одобренные владельцем RU и KY search canary queries в + `/etc/akyldash/release-canaries.json`, root-owned, mode `0600`. Убедитесь, что + они возвращают ожидаемые непустые результаты; не передавайте тексты корпуса. +- Проверьте TLS mode Cloudflare, продление origin certificate и текущую доступность + публичных `/review` и search. -## Backup and recovery gates +## Backup и восстановление -- The complete archive must remain on the owner-controlled Windows destination - `D:\Dev\akyldash\.backups`; never stage a full archive on the VPS. -- Synthetic Windows protocol checks and a disposable private-NTFS owner-ACL - fixture passed. Before relying on the flow, run its checks against the actual - D: path and local `.git/info/exclude`, then verify an approved backup is - published only after hash and manifest verification. -- Confirm D: encryption at rest. Archives contain production secrets and - private keys; the protocol encrypts transport but does not encrypt files at - rest. -- Restore a verified archive on an isolated separate target and check data, - SQLite integrity, OpenSearch, and required secrets. Do not use production as - a restore test. -- Confirm current data/index counts, corpus generation, and migration - compatibility. Image rollback does not restore data or undo schema changes. -- Set the archive retention period and keep the previous verified archive - throughout the rollback window. +- Полный архив хранится только на Windows-носителе владельца + `D:\Dev\akyldash\.backups`; не размещайте полный архив на VPS. +- Synthetic Windows protocol проверки и временная private-NTFS owner-ACL fixture + прошли. До использования проверьте реальный путь D: и локальное правило + `.git/info/exclude`; затем подтвердите, что одобренный backup публикуется только + после проверки hash и manifest. +- Подтвердите шифрование D: при хранении. Архив содержит production secrets и + private keys; протокол шифрует передачу, но не хранение файлов. +- Восстановите проверенный архив на отдельной изолированной цели и проверьте данные, + SQLite integrity, OpenSearch и необходимые secrets. Не проверяйте restore на production. +- Подтвердите текущие counts данных и индекса, corpus generation и совместимость + миграций. Откат образа не восстанавливает данные и не отменяет изменения схемы. +- Задайте срок хранения архивов и сохраняйте предыдущий проверенный архив весь + rollback window. -The installer does not create credentials, SSH configuration, canary values, -registry access, or new sudo rules. The PR has not been installed or run -against production. Windows validation passed 12 synthetic client checks, 8 -protocol checks, and a disposable real-NTFS private owner-ACL fixture. The -actual D: path, SSH, full backup, and separate-target restore remain -unverified. Keep deployment disabled until the owner records evidence for -each gate above. +Installer не создаёт credentials, SSH configuration, canary values, доступ к +registry или новые sudo rules. PR не установлен и не запускался на production. +Windows validation прошла 12 synthetic client checks, 8 protocol checks и временную +private owner-ACL fixture на NTFS. Installer и launcher на Windows не запускались; +реальный путь D:, SSH, полный backup и restore на отдельную цель не проверены. +Не включайте deployment до фиксации владельцем результатов всех проверок выше. diff --git a/infra/backup/receive_external.py b/infra/backup/receive_external.py index 812ad50..5bce695 100644 --- a/infra/backup/receive_external.py +++ b/infra/backup/receive_external.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import datetime import json import os import re @@ -18,20 +19,23 @@ from pathlib import Path from archive import verify_external_archive from external_session import (ArchiveFrameWriter, read_challenge, read_response, read_result, - MAX_ARCHIVE_BYTES, receive_archive, write_ack, write_request) + MAX_ARCHIVE_BYTES, receive_archive, validate_request, write_ack, write_request) DEFAULT_REPOSITORY = Path(r"D:\Dev\akyldash") DEFAULT_DEPLOY_SSH_ALIAS = "akyldash_release" TRIGGER_CONFIG = Path(r"C:\ProgramData\Akyldash\release-trigger.json") +AUTO_APPLY_CONFIG = Path(r"C:\ProgramData\Akyldash\release-auto-apply.json") +PREPARED_STATE_FILE = Path(r"C:\ProgramData\Akyldash\prepared-release.json") RELEASE_STATUS_COMMAND = "/usr/local/sbin/akyldash-release-status" MAX_STATUS_BYTES = 1024 STATUS_TIMEOUT_SECONDS = 180 NOT_READY_REASONS = { - "recovery_journal", "host_checkout_stale", "no_current_main_release", "main_advanced", - "already_applied", + "recovery_journal", "runtime_config_changed", "release_policy_changed", + "no_current_main_release", "main_advanced", "already_applied", } MAX_ERROR_BYTES = 8192 _WINDOWS = os.name == "nt" +DEFAULT_SSH = str(Path(r"C:\Windows\System32\OpenSSH\ssh.exe")) if _WINDOWS else "ssh" def _reparse(path: Path) -> bool: @@ -213,7 +217,7 @@ def validate_status_response(payload: bytes) -> dict | None: or value["release"] is not None): raise ValueError("release status not-ready response is invalid") return None - if value["state"] != "candidate" or value["reason"] is not None: + if value["state"] != "prepared" or value["reason"] is not None: raise ValueError("release status state is invalid") release = value["release"] if not isinstance(release, dict) or set(release) != {"run_id", "commit", "descriptor_sha256"}: @@ -223,7 +227,59 @@ def validate_status_response(payload: bytes) -> dict | None: or not isinstance(release["descriptor_sha256"], str) or not re.fullmatch(r"[0-9a-f]{64}", release["descriptor_sha256"])): raise ValueError("release status candidate identity is invalid") - return {"version": 1, "action": "release", **release} + request = {"version": 1, "action": "release", **release} + validate_request(request) + return request + + +def write_prepared_request(request: dict, path: Path = PREPARED_STATE_FILE) -> None: + validate_request(request) + if _reparse(path.parent) or not path.parent.is_dir() or _reparse(path): + raise ValueError("prepared release state path is missing or unsafe") + check_private_acl(path.parent) + if path.exists(): + check_private_acl(path) + value = { + "schema_version": 1, + "prepared_at": datetime.datetime.now(datetime.timezone.utc).isoformat(), + "release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")}, + } + payload = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n" + temporary = path.with_name(f".{path.name}.{secrets.token_hex(12)}.tmp") + try: + with temporary.open("xb") as stream: + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + check_private_acl(temporary) + os.replace(temporary, path) + check_private_acl(path) + finally: + temporary.unlink(missing_ok=True) + + +def read_prepared_request(path: Path = PREPARED_STATE_FILE) -> dict: + if _reparse(path.parent) or _reparse(path) or not path.parent.is_dir() or not path.is_file(): + raise ValueError("prepared release state is missing or unsafe") + check_private_acl(path.parent) + check_private_acl(path) + raw = path.read_bytes() + if not raw or len(raw) > MAX_STATUS_BYTES: + raise ValueError("prepared release state is empty or oversized") + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("prepared release state is invalid JSON") from error + if (not isinstance(value, dict) or set(value) != {"schema_version", "prepared_at", "release"} + or type(value["schema_version"]) is not int or value["schema_version"] != 1 + or not isinstance(value["prepared_at"], str) or not isinstance(value["release"], dict)): + raise ValueError("prepared release state has an unsupported schema") + release = value["release"] + if set(release) != {"run_id", "commit", "descriptor_sha256"}: + raise ValueError("prepared release tuple is invalid") + request = {"version": 1, "action": "release", **release} + validate_request(request) + return request def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict: @@ -255,7 +311,31 @@ def load_trigger_config(path: Path = TRIGGER_CONFIG) -> dict: return value -def query_release_status(*, ssh: str = "ssh", ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None: +def load_auto_apply_config(path: Path = AUTO_APPLY_CONFIG) -> dict: + if not _WINDOWS: + raise ValueError("automatic release apply is available only on Windows") + if (_reparse(path) or _reparse(path.parent) or not path.is_file() or not path.parent.is_dir()): + raise ValueError("automatic release apply configuration is missing or unsafe") + check_private_acl(path.parent) + check_private_acl(path) + try: + raw = path.read_bytes() + except OSError as error: + raise ValueError("automatic release apply configuration is unreadable") from error + if not raw or len(raw) > 1024: + raise ValueError("automatic release apply configuration is empty or oversized") + try: + value = json.loads(raw.decode("utf-8"), object_pairs_hook=_unique_json_object) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("automatic release apply configuration is invalid JSON") from error + if (not isinstance(value, dict) or set(value) != {"schema_version", "enabled"} + or type(value["schema_version"]) is not int or value["schema_version"] != 1 + or type(value["enabled"]) is not bool): + raise ValueError("automatic release apply configuration is invalid") + return value + + +def query_release_status(*, ssh: str = DEFAULT_SSH, ssh_alias: str = DEFAULT_DEPLOY_SSH_ALIAS) -> dict | None: if shutil.which(ssh) is None: raise ValueError("OpenSSH client is unavailable") if ssh_alias != DEFAULT_DEPLOY_SSH_ALIAS: @@ -369,7 +449,7 @@ def receive_backup(reader, writer, *, repository: Path, request: dict, expected_ raise -def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh", +def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH, ssh_alias: str = "akyldash_vps") -> None: if shutil.which(ssh) is None: raise ValueError("OpenSSH client is unavailable") @@ -407,19 +487,61 @@ def run_release(request: dict, *, repository: Path = DEFAULT_REPOSITORY, ssh: st raise -def run_release_once(*, config_file: Path = TRIGGER_CONFIG, ssh: str = "ssh") -> None: +def run_release_once(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE, + ssh: str = DEFAULT_SSH) -> None: config = load_trigger_config(config_file) if not config["enabled"]: print("Release trigger is disabled; no SSH connection opened.") return request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"]) if request is None: - print("No verified current-main release is ready; deployment not started.") + print("No verified current-main release is prepared; no deployment started.") + return + write_prepared_request(request, prepared_file) + print(f"PASS prepared release {request['commit']} (run {request['run_id']}); owner confirmation is required to deploy.") + + +def run_auto_apply_once(*, config_file: Path = TRIGGER_CONFIG, + auto_apply_file: Path = AUTO_APPLY_CONFIG, + prepared_file: Path = PREPARED_STATE_FILE, + ssh: str = DEFAULT_SSH) -> None: + config = load_trigger_config(config_file) + auto_config = load_auto_apply_config(auto_apply_file) + if not config["enabled"] or not auto_config["enabled"]: + print("Automatic apply is disabled; no SSH connection opened.") + return + request = query_release_status(ssh=ssh, ssh_alias=config["ssh_alias"]) + if request is None: + print("No verified current-main release is prepared; no deployment started.") + return + if _reparse(prepared_file): + raise ValueError("prepared release state is unsafe") + if not prepared_file.exists(): + write_prepared_request(request, prepared_file) + print(f"Prepared release {request['commit']}; automatic apply requires a later poll.") + return + previous = read_prepared_request(prepared_file) + if previous != request: + write_prepared_request(request, prepared_file) + print(f"Prepared release {request['commit']}; automatic apply requires a later poll.") return run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"]) -def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh", +def apply_prepared_release(*, config_file: Path = TRIGGER_CONFIG, prepared_file: Path = PREPARED_STATE_FILE, + ssh: str = DEFAULT_SSH, confirm=input) -> None: + if not sys.stdin.isatty(): + raise ValueError("applying a prepared release requires an interactive owner confirmation") + request = read_prepared_request(prepared_file) + answer = confirm(f"Type APPLY {request['commit']} to start the separately approved production release: ") + if answer != f"APPLY {request['commit']}": + print("Prepared release was not applied.") + return + config = load_trigger_config(config_file) + run_release(request, repository=Path(config["repository"]), ssh=ssh, ssh_alias=config["ssh_alias"]) + + +def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = DEFAULT_SSH, ssh_alias: str = "akyldash_vps") -> None: if shutil.which(ssh) is None: raise ValueError("OpenSSH client is unavailable") @@ -470,7 +592,9 @@ def run_rollback(*, repository: Path = DEFAULT_REPOSITORY, ssh: str = "ssh", def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--once", action="store_true", help="query the fixed status endpoint and deploy at most one verified release") + parser.add_argument("--once", action="store_true", help="prepare at most one verified release; never deploy") + parser.add_argument("--apply-prepared", action="store_true", help="deploy the prepared tuple after interactive owner confirmation") + parser.add_argument("--auto-apply-prepared", action="store_true", help="apply an already prepared tuple only when the separate owner auto-apply gate is enabled") parser.add_argument("--rollback", action="store_true") parser.add_argument("--data-compatible", action="store_true") parser.add_argument("--run-id") @@ -480,11 +604,21 @@ def main() -> int: args = parser.parse_args() try: if args.once: - if (args.rollback or args.data_compatible or any( + if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" or any( (args.run_id, args.commit, args.descriptor_sha256)) - or args.ssh_alias != "akyldash_vps"): + ): raise ValueError("--once accepts no release tuple, rollback, compatibility flag, or custom SSH alias") run_release_once() + elif args.apply_prepared: + if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" + or any((args.run_id, args.commit, args.descriptor_sha256))): + raise ValueError("--apply-prepared accepts no release tuple or rollback flags") + apply_prepared_release() + elif args.auto_apply_prepared: + if (args.rollback or args.data_compatible or args.ssh_alias != "akyldash_vps" + or any((args.run_id, args.commit, args.descriptor_sha256))): + raise ValueError("--auto-apply-prepared accepts no release tuple or rollback flags") + run_auto_apply_once() elif args.rollback: if not args.data_compatible or any((args.run_id, args.commit, args.descriptor_sha256)): raise ValueError("rollback requires --data-compatible and accepts no release tuple") diff --git a/infra/backup/test_receive_external.py b/infra/backup/test_receive_external.py index a4bab4a..229e138 100644 --- a/infra/backup/test_receive_external.py +++ b/infra/backup/test_receive_external.py @@ -227,8 +227,8 @@ class ExternalReceiverTests(unittest.TestCase): self.assertTrue(all(call.args[1].startswith("preflight-") for call in preflight.call_args_list)) popen.assert_not_called() - def test_status_response_accepts_only_the_fixed_candidate_tuple(self): - response = {"schema_version": 1, "state": "candidate", "reason": None, + def test_status_response_accepts_only_the_fixed_prepared_tuple(self): + response = {"schema_version": 1, "state": "prepared", "reason": None, "release": {"run_id": "302", "commit": "a" * 40, "descriptor_sha256": "b" * 64}} self.assertEqual(receive_external.validate_status_response(json.dumps(response).encode()), @@ -239,6 +239,10 @@ class ExternalReceiverTests(unittest.TestCase): self.assertIsNone(receive_external.validate_status_response( b'{"schema_version":1,"state":"not_ready","reason":"already_applied","release":null}' )) + with self.assertRaisesRegex(ValueError, "state"): + receive_external.validate_status_response( + json.dumps({**response, "state": "candidate"}).encode() + ) def test_status_response_rejects_extra_or_duplicate_fields(self): payload = (b'{"schema_version":1,"state":"not_ready","reason":"recovery_journal",' @@ -266,7 +270,7 @@ class ExternalReceiverTests(unittest.TestCase): self.assertIn("ClearAllForwardings=yes", command) self.assertEqual(popen.call_args.kwargs["stdin"], subprocess.DEVNULL) - def test_once_disabled_is_noop_and_candidate_reuses_existing_release_flow(self): + def test_once_only_prepares_and_separate_interactive_apply_requires_exact_confirmation(self): config = {"schema_version": 1, "enabled": False, "repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": receive_external.DEFAULT_DEPLOY_SSH_ALIAS} @@ -276,20 +280,78 @@ class ExternalReceiverTests(unittest.TestCase): with patch.object(receive_external, "_WINDOWS", True), \ patch.object(receive_external, "check_private_acl"), \ patch.object(receive_external, "query_release_status") as query, \ - patch.object(receive_external, "run_release") as deploy: - receive_external.run_release_once(config_file=path) + patch.object(receive_external, "run_release") as deploy, \ + patch.object(receive_external, "PREPARED_STATE_FILE", Path(directory) / "prepared.json"): + receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json") query.assert_not_called() deploy.assert_not_called() config["enabled"] = True path.write_text(json.dumps(config), encoding="utf-8") - candidate = {"version": 1, "action": "release", "run_id": "302", - "commit": "a" * 40, "descriptor_sha256": "b" * 64} - query.return_value = candidate - receive_external.run_release_once(config_file=path) - deploy.assert_called_once_with( - candidate, repository=receive_external.DEFAULT_REPOSITORY, - ssh="ssh", ssh_alias="akyldash_release") + prepared = {"version": 1, "action": "release", "run_id": "302", + "commit": "a" * 40, "descriptor_sha256": "b" * 64} + query.return_value = prepared + receive_external.run_release_once(config_file=path, prepared_file=Path(directory) / "prepared.json") + deploy.assert_not_called() + self.assertEqual(receive_external.read_prepared_request(Path(directory) / "prepared.json"), prepared) + + with patch.object(receive_external.sys.stdin, "isatty", return_value=False): + with self.assertRaisesRegex(ValueError, "interactive owner confirmation"): + receive_external.apply_prepared_release( + config_file=path, prepared_file=Path(directory) / "prepared.json", + ) + with patch.object(receive_external.sys.stdin, "isatty", return_value=True), \ + patch.object(receive_external, "run_release") as apply: + receive_external.apply_prepared_release( + config_file=path, prepared_file=Path(directory) / "prepared.json", + confirm=lambda prompt: f"APPLY {'a' * 40}", + ) + apply.assert_called_once_with( + prepared, repository=receive_external.DEFAULT_REPOSITORY, + ssh="ssh", ssh_alias="akyldash_release") + + def test_auto_apply_requires_a_prior_matching_prepared_poll_and_separate_gate(self): + previous = {"version": 1, "action": "release", "run_id": "301", + "commit": "a" * 40, "descriptor_sha256": "b" * 64} + latest = {"version": 1, "action": "release", "run_id": "302", + "commit": "c" * 40, "descriptor_sha256": "d" * 64} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + prepared_file = root / "prepared.json" + trigger_file = root / "trigger.json" + auto_file = root / "auto.json" + with patch.object(receive_external, "check_private_acl"): + receive_external.write_prepared_request(previous, prepared_file) + with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True, + "repository": str(receive_external.DEFAULT_REPOSITORY), "ssh_alias": "akyldash_release"}), \ + patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": True}), \ + patch.object(receive_external, "query_release_status", return_value=latest) as query, \ + patch.object(receive_external, "run_release") as deploy: + receive_external.run_auto_apply_once( + config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file, + ) + query.assert_called_once() + deploy.assert_not_called() + self.assertEqual(receive_external.read_prepared_request(prepared_file), latest) + + query.reset_mock() + query.return_value = latest + receive_external.run_auto_apply_once( + config_file=trigger_file, auto_apply_file=auto_file, prepared_file=prepared_file, + ) + deploy.assert_called_once_with( + latest, repository=receive_external.DEFAULT_REPOSITORY, + ssh="ssh", ssh_alias="akyldash_release", + ) + + def test_auto_apply_disabled_does_not_open_ssh(self): + with patch.object(receive_external, "load_trigger_config", return_value={"enabled": True}), \ + patch.object(receive_external, "load_auto_apply_config", return_value={"enabled": False}), \ + patch.object(receive_external, "query_release_status") as query, \ + patch.object(receive_external, "run_release") as deploy: + receive_external.run_auto_apply_once() + query.assert_not_called() + deploy.assert_not_called() def test_windows_trigger_templates_are_fixed_and_disabled(self): root = Path(__file__).resolve().parents[1] / "deploy" @@ -306,7 +368,16 @@ class ExternalReceiverTests(unittest.TestCase): self.assertEqual(task.findtext("t:Principals/t:Principal/t:RunLevel", namespaces=ns), "LeastPrivilege") self.assertEqual( task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns), - '"C:\\Program Files\\Akyldash\\receive_external.py" --once', + '-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --once', + ) + auto_config = json.loads((root / "windows-release-auto-apply.example.json").read_text(encoding="utf-8")) + self.assertEqual(auto_config, {"schema_version": 1, "enabled": False}) + auto_task = ET.parse(root / "windows-release-auto-apply.example.xml").getroot() + self.assertEqual(auto_task.findtext("t:Settings/t:Enabled", namespaces=ns), "false") + self.assertEqual(auto_task.findtext("t:Triggers/t:CalendarTrigger/t:Enabled", namespaces=ns), "false") + self.assertEqual( + auto_task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=ns), + '-B -E -s -S "C:\\Program Files\\Akyldash\\windows_release_launcher.py" --auto-apply-prepared', ) diff --git a/infra/deploy/deploy_release.py b/infra/deploy/deploy_release.py index f18ca2c..bbcde0a 100644 --- a/infra/deploy/deploy_release.py +++ b/infra/deploy/deploy_release.py @@ -146,15 +146,21 @@ def deploy_external( if not re.fullmatch(r"[0-9a-f]{64}", descriptor_sha256): raise ValueError("release descriptor digest is invalid") from release_handoff import latest_main_sha - if latest_main_sha(ROOT) != manifest["commit"]: - raise ValueError("main advanced after the external backup; refusing a stale release cutover") + from release_source import require_runtime_compatibility + + def verify_release_still_current() -> None: + if latest_main_sha(ROOT) != manifest["commit"]: + raise ValueError("main advanced after the external backup; refusing a stale release cutover") + require_runtime_compatibility( + manifest["commit"], checkout=ROOT, + policy_file=Path("/etc/akyldash/release-policy.json"), + ) + + verify_release_still_current() if not isinstance(current_before_stop, dict) or image_ids(allow_stopped=True) != current_before_stop: raise ValueError("stopped services do not match the pre-backup image snapshot") if app_images(backup_value, "external backup images") != current_before_stop: raise ValueError("external backup image identities do not match the current deployment") - repository_revision = run(["git", "-C", str(ROOT), "rev-parse", "HEAD"], capture=True) - if repository_revision != manifest["commit"]: - raise ValueError("host checkout does not match the verified release commit") current_files = { name: hashlib.sha256((ROOT / name).read_bytes()).hexdigest() for name in ("deploy/production/compose.yaml", "deploy/production/Caddyfile") @@ -195,6 +201,7 @@ def deploy_external( } journal.update({"status": "external-backup-verified", "target": target}) write_json_atomic(JOURNAL_FILE, journal) + verify_release_still_current() replace_images(ENV_FILE, old_env, new_env) journal["status"] = "references-switched" write_json_atomic(JOURNAL_FILE, journal) diff --git a/infra/deploy/external_deploy.py b/infra/deploy/external_deploy.py index 8db495b..1054a8a 100644 --- a/infra/deploy/external_deploy.py +++ b/infra/deploy/external_deploy.py @@ -12,7 +12,7 @@ from archive import compose_sources from deployment_lock import deployment_lock from deploy_release import JOURNAL_FILE, ROOT, SERVICES, compose, deploy_external, image_ids from external_session import read_request, send_backup_session, validate_request, write_result -from release_source import fetch_release +from release_source import fetch_release, require_runtime_compatibility from release_state import read_json, validate_state, write_json_atomic POLICY_FILE = Path("/etc/akyldash/release-policy.json") @@ -54,15 +54,21 @@ def execute(request: dict, *, reader, writer, policy: dict | None = None, api=No request, policy=policy, current_main=current_main, checkout=ROOT, **({"api": api} if api is not None else {}), ) + require_runtime_compatibility( + manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE, + **({"api": api} if api is not None else {}), + ) with deployment_lock(): + require_runtime_compatibility( + manifest["commit"], checkout=ROOT, policy_file=POLICY_FILE, + **({"api": api} if api is not None else {}), + ) if JOURNAL_FILE.exists() or JOURNAL_FILE.is_symlink(): raise ValueError(f"recovery journal exists at {JOURNAL_FILE}; resolve it before another release") current = image_ids() current_record = {service: current[service] for service in SERVICES} revision = deploy_release_revision() - if revision != manifest["commit"]: - raise ValueError("installed host checkout does not match the verified release commit") previous_state = None if STATE_FILE.exists() or STATE_FILE.is_symlink(): previous_state = validate_state(read_json(STATE_FILE)) diff --git a/infra/deploy/external_status.py b/infra/deploy/external_status.py index dc663ce..8588f73 100644 --- a/infra/deploy/external_status.py +++ b/infra/deploy/external_status.py @@ -1,50 +1,37 @@ #!/usr/bin/env python3 -"""Read-only selector for one fully verified current-main release candidate.""" +"""Read-only preparation endpoint for one fully verified current-main release.""" from __future__ import annotations import json import os -import re -import subprocess import sys from pathlib import Path from deploy_release import JOURNAL_FILE, ROOT, STATE_FILE from release_handoff import latest_main_sha -from release_source import fetch_release, latest_release_request +from release_source import fetch_release, latest_release_request, runtime_compatibility_reason from release_state import read_json, validate_state POLICY_FILE = Path("/etc/akyldash/release-policy.json") STATUS_MAX_BYTES = 1024 -SHA_RE = re.compile(r"[0-9a-f]{40}\Z") - - -def checkout_sha(checkout: Path) -> str: - result = subprocess.run( - ["git", "-C", str(checkout), "rev-parse", "HEAD"], - check=True, text=True, capture_output=True, timeout=10, - ) - value = result.stdout.strip() - if not SHA_RE.fullmatch(value): - raise ValueError("production checkout revision is invalid") - return value def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_FILE, policy_file: Path = POLICY_FILE, state_file: Path = STATE_FILE, - main_lookup=latest_main_sha, - checkout_lookup=checkout_sha) -> dict: + main_lookup=latest_main_sha) -> dict: if os.geteuid() != 0: raise ValueError("release status command must run as root") if journal.exists() or journal.is_symlink(): return {"schema_version": 1, "state": "not_ready", "reason": "recovery_journal", "release": None} current_main = main_lookup(checkout) - if checkout_lookup(checkout) != current_main: - return {"schema_version": 1, "state": "not_ready", "reason": "host_checkout_stale", "release": None} - api_args = {"api": api} if api is not None else {} + reason = runtime_compatibility_reason( + current_main, checkout=checkout, policy_file=policy_file, **api_args, + ) + if reason is not None: + return {"schema_version": 1, "state": "not_ready", "reason": reason, "release": None} request = latest_release_request(current_main=current_main, checkout=checkout, **api_args) if request is None: return {"schema_version": 1, "state": "not_ready", "reason": "no_current_main_release", "release": None} @@ -70,7 +57,7 @@ def status_document(*, api=None, checkout: Path = ROOT, journal: Path = JOURNAL_ return {"schema_version": 1, "state": "not_ready", "reason": "already_applied", "release": None} return { "schema_version": 1, - "state": "candidate", + "state": "prepared", "reason": None, "release": {key: request[key] for key in ("run_id", "commit", "descriptor_sha256")}, } diff --git a/infra/deploy/install_windows_release_client.ps1 b/infra/deploy/install_windows_release_client.ps1 new file mode 100644 index 0000000..6491035 --- /dev/null +++ b/infra/deploy/install_windows_release_client.ps1 @@ -0,0 +1,383 @@ +param( + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{40}$')] + [string]$ExpectedCommit, + [Parameter(Mandatory = $true)] + [string]$BundlePath, + [Parameter(Mandatory = $true)] + [ValidatePattern('^[0-9a-f]{64}$')] + [string]$ExpectedBundleSha256 +) + +$ErrorActionPreference = 'Stop' +$python = 'C:\Python313\python.exe' +$git = 'C:\Program Files\Git\cmd\git.exe' +$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe' +$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe' +$appRoot = 'C:\Program Files\Akyldash' +$app = Join-Path $appRoot "client-$ExpectedCommit" +$programData = 'C:\ProgramData\Akyldash' +$trigger = Join-Path $programData 'release-trigger.json' +$autoApply = Join-Path $programData 'release-auto-apply.json' +$mutationRights = [System.Security.AccessControl.FileSystemRights]( + [int][System.Security.AccessControl.FileSystemRights]::WriteData -bor + [int][System.Security.AccessControl.FileSystemRights]::AppendData -bor + [int][System.Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor + [int][System.Security.AccessControl.FileSystemRights]::WriteAttributes -bor + [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [int][System.Security.AccessControl.FileSystemRights]::Delete -bor + [int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [int][System.Security.AccessControl.FileSystemRights]::TakeOwnership +) +$pathReplacementRights = [System.Security.AccessControl.FileSystemRights]( + [int][System.Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor + [int][System.Security.AccessControl.FileSystemRights]::Delete -bor + [int][System.Security.AccessControl.FileSystemRights]::ChangePermissions -bor + [int][System.Security.AccessControl.FileSystemRights]::TakeOwnership +) + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw 'Run this reviewed bootstrap from an elevated administrator session.' +} + +$signature = Get-AuthenticodeSignature -LiteralPath $python +if ($signature.Status -ne 'Valid' -or $signature.SignerCertificate.Subject -notmatch 'Python Software Foundation') { + throw 'The pinned Python interpreter does not have the verified Python Software Foundation signature.' +} +function Assert-ProtectedPath([string]$Path, [bool]$AllowOwnerWrite = $false) { + $item = Get-Item -LiteralPath $Path -Force + if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { throw "Refusing reparse path: $Path" } + $acl = Get-Acl -LiteralPath $Path + $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + if ($AllowOwnerWrite) { + $approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value) + } else { + $approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller) + } + if ($ownerSid -notin $approvedOwners) { throw "Protected path has an unapproved owner: $Path" } + if ($AllowOwnerWrite) { + $allowed = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value) + foreach ($rule in $acl.Access) { + $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if ($rule.AccessControlType -ne 'Allow' -or $sid -notin $allowed -or + ($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -ne [System.Security.AccessControl.FileSystemRights]::FullControl) { + throw "Private state path ACL must grant only full control to owner/SYSTEM/Administrators: $Path" + } + } + $ownerHasFullControl = @($acl.Access | Where-Object { + $_.AccessControlType -eq 'Allow' -and + $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -eq $identity.User.Value -and + ($_.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::FullControl) -eq [System.Security.AccessControl.FileSystemRights]::FullControl + }).Count -gt 0 + if (-not $ownerHasFullControl) { throw "Task owner needs full control on protected state path: $Path" } + } else { + $writableUntrusted = @($acl.Access | Where-Object { + $_.AccessControlType -eq 'Allow' -and + (([int]$_.FileSystemRights -band [int]$mutationRights) -ne 0) -and + $_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller) + }) + if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" } + } +} +function Set-TrustedCodeOwner([string]$Path) { + $acl = Get-Acl -LiteralPath $Path + $administratorSid = [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544') + $acl.SetOwner($administratorSid) + Set-Acl -LiteralPath $Path -AclObject $acl +} +function Assert-TrustedPath([string]$Path, [bool]$ParentsOnly = $false) { + $item = Get-Item -LiteralPath $Path -Force + if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Refusing missing, non-filesystem, or reparse protected path: $Path" + } + $trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464' + $current = if ($ParentsOnly) { $item.Parent } else { $item } + $isLeaf = -not $ParentsOnly + while ($null -ne $current) { + if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw "Protected path contains a reparse point: $($current.FullName)" + } + $acl = Get-Acl -LiteralPath $current.FullName + $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value + if ($ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) { + throw "Protected path has an untrusted owner: $($current.FullName)" + } + $checkedRights = if ($isLeaf) { $mutationRights } else { $pathReplacementRights } + foreach ($rule in $acl.Access) { + $sid = $rule.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value + if (($rule.PropagationFlags -band [System.Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0) { continue } + if ($rule.AccessControlType -eq 'Allow' -and + (([int]$rule.FileSystemRights -band [int]$checkedRights) -ne 0) -and + $sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) { + throw "Protected path grants replacement/write access outside trusted system owners: $($current.FullName)" + } + } + $current = $current.Parent + $isLeaf = $false + } +} + +foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool } + +Assert-ProtectedPath (Split-Path -Parent $python) +Assert-ProtectedPath $python +if (-not (Test-Path -LiteralPath $appRoot -PathType Container)) { throw 'Protected receiver directory is missing; provision it through the reviewed owner procedure first.' } +Assert-TrustedPath $appRoot +Assert-ProtectedPath $appRoot +if (Test-Path -LiteralPath $app) { throw 'Versioned receiver destination already exists; inspect it manually instead of overwriting.' } +if (-not (Test-Path -LiteralPath $programData -PathType Container)) { throw 'Protected ProgramData directory is missing; provision it through the reviewed owner procedure first.' } +Assert-ProtectedPath $programData -AllowOwnerWrite $true +Assert-TrustedPath $programData -ParentsOnly $true +if (Test-Path -LiteralPath $trigger) { Assert-ProtectedPath $trigger -AllowOwnerWrite $true } +if (Test-Path -LiteralPath $trigger) { + $triggerItem = Get-Item -LiteralPath $trigger + if ($triggerItem.Length -gt 4096) { throw 'Existing trigger configuration exceeds its size limit.' } + $triggerConfig = Get-Content -LiteralPath $trigger -Raw | ConvertFrom-Json + $triggerFields = @($triggerConfig.PSObject.Properties.Name | Sort-Object) -join ',' + if ($triggerConfig.schema_version -ne 1 -or $triggerConfig.enabled -ne $false -or + $triggerConfig.repository -ne 'D:\Dev\akyldash' -or $triggerConfig.ssh_alias -ne 'akyldash_release' -or + $triggerFields -ne 'enabled,repository,schema_version,ssh_alias') { + throw 'Existing trigger configuration is not the fixed disabled configuration.' + } +} +if (Test-Path -LiteralPath $autoApply) { + Assert-ProtectedPath $autoApply -AllowOwnerWrite $true + $autoItem = Get-Item -LiteralPath $autoApply + if ($autoItem.Length -gt 1024) { throw 'Existing auto-apply configuration exceeds its size limit.' } + $autoConfig = Get-Content -LiteralPath $autoApply -Raw | ConvertFrom-Json + $autoFields = @($autoConfig.PSObject.Properties.Name | Sort-Object) -join ',' + if ($autoConfig.schema_version -ne 1 -or $autoConfig.enabled -ne $false -or + $autoFields -ne 'enabled,schema_version') { + throw 'Existing auto-apply configuration must be disabled and match its fixed schema.' + } +} + +function Read-BoundedZipEntry($Entry, [long]$Maximum) { + if ($Entry.Length -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" } + $inputStream = $Entry.Open() + $buffer = [IO.MemoryStream]::new() + try { + $chunk = New-Object byte[] 8192 + while (($count = $inputStream.Read($chunk, 0, $chunk.Length)) -gt 0) { + if ($buffer.Length + $count -gt $Maximum) { throw "Receiver bundle entry exceeds its size limit: $($Entry.FullName)" } + $buffer.Write($chunk, 0, $count) + } + if ($buffer.Length -ne $Entry.Length) { throw "Receiver bundle entry length is invalid: $($Entry.FullName)" } + return ,$buffer.ToArray() + } finally { + $inputStream.Dispose() + $buffer.Dispose() + } +} +function Assert-ReceiverBundleEntryMode($Entry) { + $entryMode = (([long]$Entry.ExternalAttributes -shr 16) -band 0xFFFF) + if ($entryMode -ne 0x8180) { throw "Receiver bundle entry has an unsafe file mode: $($Entry.FullName)" } +} +function Get-LowerHex([byte[]]$Bytes, [bool]$UseSha1 = $false) { + $algorithm = if ($UseSha1) { [Security.Cryptography.SHA1]::Create() } else { [Security.Cryptography.SHA256]::Create() } + try { return [BitConverter]::ToString($algorithm.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() } + finally { $algorithm.Dispose() } +} +function Get-GitObjectHex([string]$Kind, [byte[]]$Bytes) { + $header = [Text.Encoding]::ASCII.GetBytes("$Kind $($Bytes.Length)`0") + $payload = New-Object byte[] ($header.Length + $Bytes.Length) + [Array]::Copy($header, 0, $payload, 0, $header.Length) + [Array]::Copy($Bytes, 0, $payload, $header.Length, $Bytes.Length) + return Get-LowerHex -Bytes $payload -UseSha1 $true +} +function Get-GitTreeEntries([byte[]]$Bytes) { + $entries = @{} + $offset = 0 + $utf8Strict = [Text.UTF8Encoding]::new($false, $true) + while ($offset -lt $Bytes.Length) { + $space = [Array]::IndexOf($Bytes, [byte]32, $offset) + if ($space -lt $offset) { throw 'Git tree object has a malformed mode field.' } + $nul = [Array]::IndexOf($Bytes, [byte]0, $space + 1) + if ($nul -lt $space -or $nul + 21 -gt $Bytes.Length) { throw 'Git tree object has a malformed entry.' } + $mode = [Text.Encoding]::ASCII.GetString($Bytes, $offset, $space - $offset) + $name = $utf8Strict.GetString($Bytes, $space + 1, $nul - $space - 1) + if (-not $name -or $name -in @('.', '..') -or $entries.ContainsKey($name)) { throw 'Git tree object has an invalid or duplicate name.' } + $objectBytes = New-Object byte[] 20 + [Array]::Copy($Bytes, $nul + 1, $objectBytes, 0, 20) + $entries[$name] = [PSCustomObject]@{ + mode = $mode + sha1 = [BitConverter]::ToString($objectBytes).Replace('-', '').ToLowerInvariant() + } + $offset = $nul + 21 + } + return ,$entries +} + +# The package is opened once with write/delete sharing denied. Its bytes are +# hashed and held in memory before any installation write; no checkout or Git +# command participates in this elevated stage. +$bundleItem = Get-Item -LiteralPath $BundlePath -Force +if ($bundleItem -isnot [IO.FileInfo] -or ($bundleItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Receiver bundle must be a regular non-reparse file.' +} +$bundleStream = [IO.File]::Open([IO.Path]::GetFullPath($BundlePath), [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read) +$zip = $null +$verifiedFiles = @{} +try { + if ($bundleStream.Length -le 0 -or $bundleStream.Length -gt 524288) { throw 'Receiver bundle has an invalid size.' } + $bundleHasher = [Security.Cryptography.SHA256]::Create() + try { $bundleHash = [BitConverter]::ToString($bundleHasher.ComputeHash($bundleStream)).Replace('-', '').ToLowerInvariant() } + finally { $bundleHasher.Dispose() } + if ($bundleHash -ne $ExpectedBundleSha256) { throw 'Receiver bundle SHA-256 does not match ExpectedBundleSha256.' } + $bundleStream.Position = 0 + Add-Type -AssemblyName System.IO.Compression + $zip = [System.IO.Compression.ZipArchive]::new($bundleStream, [IO.Compression.ZipArchiveMode]::Read, $true) + $expectedEntries = @('manifest.json', 'objects/commit.bin', 'objects/root-tree.bin', + 'objects/infra-tree.bin', 'objects/backup-tree.bin', 'objects/deploy-tree.bin', + 'files/archive.py', 'files/external_session.py', + 'files/receive_external.py', 'files/windows-release-trigger.example.xml', + 'files/windows_release_launcher.py') + $entryNames = @($zip.Entries | ForEach-Object { $_.FullName }) + if ($entryNames.Count -ne $expectedEntries.Count -or + (@($entryNames | Select-Object -Unique).Count -ne $expectedEntries.Count)) { + throw 'Receiver bundle contains duplicate or unexpected entries.' + } + foreach ($expectedEntry in $expectedEntries) { + if ($entryNames -cnotcontains $expectedEntry) { throw "Receiver bundle is missing an exact required entry: $expectedEntry" } + } + foreach ($zipEntry in $zip.Entries) { + Assert-ReceiverBundleEntryMode -Entry $zipEntry + } + $manifestBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('manifest.json') -Maximum 16384 + $utf8Strict = [Text.UTF8Encoding]::new($false, $true) + $manifest = $utf8Strict.GetString($manifestBytes) | ConvertFrom-Json + $manifestFields = @($manifest.PSObject.Properties.Name | Sort-Object) -join ',' + if ($manifestFields -ne 'commit,files,schema_version,trees' -or + ($manifest.schema_version -isnot [int] -and $manifest.schema_version -isnot [long]) -or + $manifest.schema_version -ne 1 -or $manifest.commit -isnot [string] -or + $manifest.commit -cne $ExpectedCommit -or $manifest.files -isnot [Array] -or + @($manifest.files).Count -ne 5 -or $manifest.trees -isnot [Array] -or @($manifest.trees).Count -ne 4) { + throw 'Receiver bundle manifest does not match the expected commit and schema.' + } + $commitBytes = Read-BoundedZipEntry -Entry $zip.GetEntry('objects/commit.bin') -Maximum 16384 + if ((Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes) -cne $ExpectedCommit) { + throw 'Receiver bundle Git commit object does not match ExpectedCommit.' + } + $commitText = [Text.Encoding]::ASCII.GetString($commitBytes) + $headerEnd = $commitText.IndexOf("`n`n", [StringComparison]::Ordinal) + if ($headerEnd -lt 1) { throw 'Receiver bundle commit header is malformed.' } + $treeHeaders = [regex]::Matches($commitText.Substring(0, $headerEnd), '(?m)^tree ([0-9a-f]{40})$') + if ($treeHeaders.Count -ne 1) { throw 'Receiver bundle commit has no unique root tree reference.' } + + $treeEntries = @{} + $treeObjectNames = @{ + '' = 'objects/root-tree.bin' + infra = 'objects/infra-tree.bin' + 'infra/backup' = 'objects/backup-tree.bin' + 'infra/deploy' = 'objects/deploy-tree.bin' + } + $seenTrees = @{} + foreach ($tree in $manifest.trees) { + $treeFields = @($tree.PSObject.Properties.Name | Sort-Object) -join ',' + if ($treeFields -ne 'path,sha1' -or $tree.path -isnot [string] -or + $tree.path -cnotin @('', 'infra', 'infra/backup', 'infra/deploy') -or + $seenTrees.ContainsKey($tree.path) -or $tree.sha1 -cnotmatch '^[0-9a-f]{40}$') { + throw 'Receiver bundle tree manifest is invalid.' + } + $treeData = Read-BoundedZipEntry -Entry $zip.GetEntry($treeObjectNames[$tree.path]) -Maximum 262144 + if ((Get-GitObjectHex -Kind 'tree' -Bytes $treeData) -cne $tree.sha1) { + throw "Receiver bundle tree object identity does not match its manifest: $($tree.path)" + } + $treeEntries[$tree.path] = Get-GitTreeEntries -Bytes $treeData + $seenTrees[$tree.path] = $tree.sha1 + } + if ($seenTrees.Count -ne 4 -or $treeHeaders[0].Groups[1].Value -cne $seenTrees['']) { + throw 'Receiver bundle commit does not bind the declared root tree.' + } + $rootInfra = $treeEntries['']['infra'] + $infraBackup = $treeEntries['infra']['backup'] + $infraDeploy = $treeEntries['infra']['deploy'] + if ($rootInfra.mode -notin @('40000', '040000') -or $rootInfra.sha1 -cne $seenTrees['infra'] -or + $infraBackup.mode -notin @('40000', '040000') -or $infraBackup.sha1 -cne $seenTrees['infra/backup'] -or + $infraDeploy.mode -notin @('40000', '040000') -or $infraDeploy.sha1 -cne $seenTrees['infra/deploy']) { + throw 'Receiver bundle directory tree chain does not match the commit.' + } + $allowedFiles = @('archive.py', 'external_session.py', 'receive_external.py', + 'windows-release-trigger.example.xml', 'windows_release_launcher.py') + $seenFiles = @{} + foreach ($file in $manifest.files) { + $fileFields = @($file.PSObject.Properties.Name | Sort-Object) -join ',' + if ($fileFields -ne 'git_blob_sha1,path,sha256,size' -or $file.path -isnot [string] -or + $file.path -cnotin $allowedFiles -or $seenFiles.ContainsKey($file.path) -or + $file.git_blob_sha1 -isnot [string] -or $file.git_blob_sha1 -cnotmatch '^[0-9a-f]{40}$' -or + $file.sha256 -isnot [string] -or $file.sha256 -cnotmatch '^[0-9a-f]{64}$' -or + ($file.size -isnot [int] -and $file.size -isnot [long]) -or $file.size -lt 0 -or $file.size -gt 262144) { + throw 'Receiver bundle manifest contains an invalid file record.' + } + $entry = $zip.GetEntry("files/$($file.path)") + $data = Read-BoundedZipEntry -Entry $entry -Maximum 262144 + if ($data.Length -ne [long]$file.size -or + (Get-LowerHex -Bytes $data) -cne $file.sha256) { + throw "Receiver bundle file digest or size does not match its manifest: $($file.path)" + } + if ((Get-GitObjectHex -Kind 'blob' -Bytes $data) -cne $file.git_blob_sha1) { + throw "Receiver bundle Git blob identity does not match its manifest: $($file.path)" + } + $treePath = if ($file.path -in @('archive.py', 'external_session.py', 'receive_external.py')) { 'infra/backup' } else { 'infra/deploy' } + $treeFile = $treeEntries[$treePath][$file.path] + if ($null -eq $treeFile -or $treeFile.mode -ne '100644' -or $treeFile.sha1 -cne $file.git_blob_sha1) { + throw "Receiver bundle commit tree does not contain the exact source file: $($file.path)" + } + $seenFiles[$file.path] = $true + $verifiedFiles[$file.path] = $data + } + if ($seenFiles.Count -ne $allowedFiles.Count) { throw 'Receiver bundle manifest omitted a required file.' } +} finally { + if ($null -ne $zip) { $zip.Dispose() } + $bundleStream.Dispose() +} + +$appSecurity = Get-Acl -LiteralPath $appRoot +$appSecurity.SetOwner([Security.Principal.SecurityIdentifier]::new('S-1-5-32-544')) +$null = [IO.Directory]::CreateDirectory($app, $appSecurity) +Set-TrustedCodeOwner $app +Assert-ProtectedPath $app +foreach ($name in @('receive_external.py', 'archive.py', 'external_session.py', 'windows_release_launcher.py')) { + $destination = Join-Path $app $name + [IO.File]::WriteAllBytes($destination, $verifiedFiles[$name]) + Set-TrustedCodeOwner $destination + Assert-ProtectedPath $destination +} +if (-not (Test-Path -LiteralPath $trigger)) { + $config = '{"schema_version":1,"enabled":false,"repository":"D:\\Dev\\akyldash","ssh_alias":"akyldash_release"}' + Set-Content -LiteralPath $trigger -Value $config -Encoding Ascii -NoNewline +} +if (-not (Test-Path -LiteralPath $autoApply)) { + Set-Content -LiteralPath $autoApply -Value '{"schema_version":1,"enabled":false}' -Encoding Ascii -NoNewline +} +Assert-ProtectedPath $trigger -AllowOwnerWrite $true +Assert-ProtectedPath $autoApply -AllowOwnerWrite $true + +$template = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml']) +$ns = [System.Xml.XmlNamespaceManager]::new($template.NameTable) +$ns.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task') +$sid = $identity.User.Value +$template.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $ns).InnerText = $sid +$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $ns).InnerText = $python +$launcher = Join-Path $app 'windows_release_launcher.py' +$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $ns).InnerText = "-B -E -s -S `"$launcher`" --once" +$template.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $ns).InnerText = $app +$template.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $ns).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss') +$templatePath = Join-Path $programData 'Akyldash-ReleaseTrigger-disabled.xml' +$template.Save($templatePath) +$applyTemplate = [xml][Text.Encoding]::UTF8.GetString($verifiedFiles['windows-release-trigger.example.xml']) +$applyNs = [System.Xml.XmlNamespaceManager]::new($applyTemplate.NameTable) +$applyNs.AddNamespace('t', 'http://schemas.microsoft.com/windows/2004/02/mit/task') +$applyTemplate.SelectSingleNode('/t:Task/t:RegistrationInfo/t:URI', $applyNs).InnerText = '\Akyldash\ReleaseAutoApply' +$applyTemplate.SelectSingleNode('/t:Task/t:Principals/t:Principal/t:UserId', $applyNs).InnerText = $sid +$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Command', $applyNs).InnerText = $python +$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:Arguments', $applyNs).InnerText = "-B -E -s -S `"$launcher`" --auto-apply-prepared" +$applyTemplate.SelectSingleNode('/t:Task/t:Actions/t:Exec/t:WorkingDirectory', $applyNs).InnerText = $app +$applyTemplate.SelectSingleNode('/t:Task/t:Triggers/t:CalendarTrigger/t:StartBoundary', $applyNs).InnerText = (Get-Date).AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss') +$applyTemplate.Save((Join-Path $programData 'Akyldash-ReleaseAutoApply-disabled.xml')) +Write-Output "Prepared disabled receiver files from verified source commit $ExpectedCommit." +Write-Output 'No task was registered or enabled; both configuration gates remain disabled.' diff --git a/infra/deploy/prepare_windows_release_bundle.py b/infra/deploy/prepare_windows_release_bundle.py new file mode 100644 index 0000000..d177ec5 --- /dev/null +++ b/infra/deploy/prepare_windows_release_bundle.py @@ -0,0 +1,309 @@ +"""Build a small immutable Windows receiver bundle from one Git commit.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import re +import subprocess +import zipfile +from pathlib import Path + +FILES = { + "archive.py": "infra/backup/archive.py", + "external_session.py": "infra/backup/external_session.py", + "receive_external.py": "infra/backup/receive_external.py", + "windows-release-trigger.example.xml": "infra/deploy/windows-release-trigger.example.xml", + "windows_release_launcher.py": "infra/deploy/windows_release_launcher.py", +} +MAX_BUNDLE_BYTES = 512 * 1024 +MAX_FILE_BYTES = 262144 +TREE_OBJECTS = { + "": "objects/root-tree.bin", + "infra": "objects/infra-tree.bin", + "infra/backup": "objects/backup-tree.bin", + "infra/deploy": "objects/deploy-tree.bin", +} + + +def _git(repository: Path, *arguments: str) -> bytes: + result = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + ) + return result.stdout + + +def _blob_sha(data: bytes) -> str: + return _git_object_sha("blob", data) + + +def _git_object_sha(kind: str, data: bytes) -> str: + return hashlib.sha1(kind.encode("ascii") + b" " + str(len(data)).encode("ascii") + b"\0" + data).hexdigest() + + +def _tree_entry(repository: Path, tree: str, name: str) -> tuple[str, str, str]: + records = _git(repository, "ls-tree", "-z", tree, "--", name).split(b"\0") + matches = [] + for record in records: + if not record: + continue + header, raw_path = record.split(b"\t", 1) + mode, kind, object_id = header.decode("ascii").split(" ") + if raw_path.decode("utf-8") == name: + matches.append((mode, kind, object_id)) + if len(matches) != 1: + raise ValueError(f"Git tree is missing or duplicates required path: {name}") + return matches[0] + + +def _parse_git_tree(data: bytes) -> dict[str, tuple[str, str]]: + entries: dict[str, tuple[str, str]] = {} + offset = 0 + while offset < len(data): + space = data.find(b" ", offset) + nul = data.find(b"\0", space + 1) if space >= 0 else -1 + if space < offset or nul < 0 or nul + 21 > len(data): + raise ValueError("Git tree object is malformed") + mode = data[offset:space].decode("ascii") + name = data[space + 1:nul].decode("utf-8") + object_id = data[nul + 1:nul + 21].hex() + if not name or name in {".", ".."} or name in entries: + raise ValueError("Git tree object has an invalid or duplicate name") + entries[name] = (mode, object_id) + offset = nul + 21 + return entries + + +def _strict_json_object(pairs): + value = {} + for key, item in pairs: + if key in value: + raise ValueError("receiver bundle manifest has duplicate keys") + value[key] = item + return value + + +def verify_bundle(payload: bytes, expected_commit: str, expected_sha256: str) -> dict[str, bytes]: + """Verify a generated or transferred bundle before an installer consumes it.""" + if len(payload) <= 0 or len(payload) > MAX_BUNDLE_BYTES: + raise ValueError("receiver bundle has an invalid size") + if hashlib.sha256(payload).hexdigest() != expected_sha256: + raise ValueError("receiver bundle SHA-256 does not match") + if not re.fullmatch(r"[0-9a-f]{40}", expected_commit): + raise ValueError("expected commit must be a full lowercase Git SHA-1") + expected_entries = { + "manifest.json", "objects/commit.bin", *TREE_OBJECTS.values(), + *(f"files/{name}" for name in FILES), + } + try: + archive = zipfile.ZipFile(io.BytesIO(payload)) + except (zipfile.BadZipFile, OSError) as error: + raise ValueError("receiver bundle is not a valid ZIP") from error + with archive: + infos = archive.infolist() + names = [item.filename for item in infos] + if len(names) != len(expected_entries) or len(set(names)) != len(expected_entries) or set(names) != expected_entries: + raise ValueError("receiver bundle has duplicate, missing, or unexpected entries") + for item in infos: + mode = (item.external_attr >> 16) & 0xFFFF + if mode != 0o100600 or item.compress_type != zipfile.ZIP_STORED: + raise ValueError("receiver bundle entry has an unsafe mode or compression method") + maximum = 16384 if item.filename == "manifest.json" else MAX_FILE_BYTES + if item.file_size > maximum: + raise ValueError("receiver bundle entry exceeds its size limit") + contents = {item.filename: archive.read(item) for item in infos} + + try: + manifest = json.loads(contents["manifest.json"].decode("ascii"), object_pairs_hook=_strict_json_object) + except (UnicodeError, json.JSONDecodeError) as error: + raise ValueError("receiver bundle manifest is invalid") from error + if (not isinstance(manifest, dict) or set(manifest) != {"schema_version", "commit", "files", "trees"} + or type(manifest["schema_version"]) is not int or manifest["schema_version"] != 1 + or manifest["commit"] != expected_commit): + raise ValueError("receiver bundle manifest does not match the expected commit and schema") + + commit_object = contents["objects/commit.bin"] + if _git_object_sha("commit", commit_object) != expected_commit: + raise ValueError("receiver bundle commit object does not match expected SHA") + commit_header = commit_object.split(b"\n\n", 1)[0] + commit_trees = re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header) + if len(commit_trees) != 1: + raise ValueError("receiver bundle commit has no unique root tree") + + if not isinstance(manifest["trees"], list) or len(manifest["trees"]) != len(TREE_OBJECTS): + raise ValueError("receiver bundle tree manifest is invalid") + tree_hashes: dict[str, str] = {} + tree_entries: dict[str, dict[str, tuple[str, str]]] = {} + for record in manifest["trees"]: + if (not isinstance(record, dict) or set(record) != {"path", "sha1"} + or not isinstance(record["path"], str) or record["path"] not in TREE_OBJECTS + or record["path"] in tree_hashes + or not isinstance(record["sha1"], str) or not re.fullmatch(r"[0-9a-f]{40}", record["sha1"])): + raise ValueError("receiver bundle tree record is invalid") + path = record["path"] + data = contents[TREE_OBJECTS[path]] + if _git_object_sha("tree", data) != record["sha1"]: + raise ValueError(f"receiver bundle tree hash does not match: {path}") + tree_hashes[path] = record["sha1"] + tree_entries[path] = _parse_git_tree(data) + if set(tree_hashes) != set(TREE_OBJECTS) or tree_hashes[""].encode("ascii") != commit_trees[0]: + raise ValueError("receiver bundle commit does not bind the declared root tree") + for parent, name, child in (("", "infra", "infra"), + ("infra", "backup", "infra/backup"), + ("infra", "deploy", "infra/deploy")): + mode, child_sha = tree_entries[parent].get(name, ("", "")) + if mode not in {"40000", "040000"} or child_sha != tree_hashes[child]: + raise ValueError(f"receiver bundle directory tree chain is invalid at {parent or '/'}/{name}") + + if not isinstance(manifest["files"], list) or len(manifest["files"]) != len(FILES): + raise ValueError("receiver bundle file manifest is invalid") + verified: dict[str, bytes] = {} + for record in manifest["files"]: + if (not isinstance(record, dict) or set(record) != {"path", "git_blob_sha1", "sha256", "size"} + or not isinstance(record["path"], str) or record["path"] not in FILES or record["path"] in verified + or type(record["size"]) is not int or record["size"] < 0 or record["size"] > MAX_FILE_BYTES + or not isinstance(record["git_blob_sha1"], str) + or not re.fullmatch(r"[0-9a-f]{40}", record["git_blob_sha1"]) + or not isinstance(record["sha256"], str) + or not re.fullmatch(r"[0-9a-f]{64}", record["sha256"])): + raise ValueError("receiver bundle file record is invalid") + name = record["path"] + data = contents[f"files/{name}"] + tree_path = "infra/backup" if FILES[name].startswith("infra/backup/") else "infra/deploy" + mode, blob_sha = tree_entries[tree_path].get(name, ("", "")) + if (mode != "100644" or blob_sha != record["git_blob_sha1"] + or _git_object_sha("blob", data) != blob_sha + or hashlib.sha256(data).hexdigest() != record["sha256"] + or len(data) != record["size"]): + raise ValueError(f"receiver bundle file does not match its commit tree: {name}") + verified[name] = data + if set(verified) != set(FILES): + raise ValueError("receiver bundle omits a required source file") + return verified + + +def read_commit_files(repository: Path, commit: str) -> tuple[ + dict[str, bytes], list[dict[str, object]], dict[str, bytes], list[dict[str, str]]]: + if not re.fullmatch(r"[0-9a-f]{40}", commit): + raise ValueError("expected commit must be a full lowercase Git SHA-1") + if repository.is_symlink(): + raise ValueError("repository must be a real directory") + repository = repository.resolve(strict=True) + if not repository.is_dir(): + raise ValueError("repository must be a real directory") + if _git(repository, "cat-file", "-t", commit).strip() != b"commit": + raise ValueError("expected object is not a commit") + main_commit = _git(repository, "rev-parse", "refs/heads/main").strip().decode("ascii") + if main_commit != commit: + raise ValueError("expected commit must equal the local main branch tip") + commit_object = _git(repository, "cat-file", "commit", commit) + if _git_object_sha("commit", commit_object) != commit: + raise ValueError("commit object bytes do not match the expected SHA") + commit_header = commit_object.split(b"\n\n", 1)[0] + commit_trees = [value.decode("ascii") for value in + re.findall(rb"(?m)^tree ([0-9a-f]{40})$", commit_header)] + if len(commit_trees) != 1: + raise ValueError("commit object has no unique root tree") + tree_ids = {"": commit_trees[0]} + tree_ids["infra"] = _tree_entry(repository, tree_ids[""], "infra")[2] + tree_ids["infra/backup"] = _tree_entry(repository, tree_ids["infra"], "backup")[2] + tree_ids["infra/deploy"] = _tree_entry(repository, tree_ids["infra"], "deploy")[2] + tree_objects: dict[str, bytes] = {"objects/commit.bin": commit_object} + manifest_trees: list[dict[str, str]] = [] + for tree_path, tree_sha in sorted(tree_ids.items()): + data = _git(repository, "cat-file", "tree", tree_sha) + if _git_object_sha("tree", data) != tree_sha: + raise ValueError(f"Git tree bytes do not match their object ID: {tree_path or '/'}") + tree_objects[TREE_OBJECTS[tree_path]] = data + manifest_trees.append({"path": tree_path, "sha1": tree_sha}) + records = _git(repository, "ls-tree", "-z", commit, "--", *FILES.values()).split(b"\0") + tree_entries: dict[str, tuple[str, str, str]] = {} + for record in records: + if not record: + continue + header, raw_path = record.split(b"\t", 1) + mode, kind, object_id = header.decode("ascii").split(" ") + tree_entries[raw_path.decode("utf-8")] = (mode, kind, object_id) + if set(tree_entries) != set(FILES.values()): + raise ValueError("commit does not contain the exact receiver allowlist") + + contents: dict[str, bytes] = {} + manifest_files: list[dict[str, object]] = [] + for name, source_path in sorted(FILES.items()): + mode, kind, object_id = tree_entries[source_path] + if mode != "100644" or kind != "blob": + raise ValueError(f"receiver source is not a regular Git blob: {source_path}") + data = _git(repository, "cat-file", "blob", object_id) + if _blob_sha(data) != object_id: + raise ValueError(f"Git blob bytes do not match the commit object: {source_path}") + contents[name] = data + manifest_files.append({ + "path": name, + "git_blob_sha1": object_id, + "sha256": hashlib.sha256(data).hexdigest(), + "size": len(data), + }) + for name, source_path in FILES.items(): + parent_path = "infra/backup" if source_path.startswith("infra/backup/") else "infra/deploy" + _mode, _kind, expected_blob = tree_entries[source_path] + mode, kind, tree_blob = _tree_entry(repository, tree_ids[parent_path], name) + if mode != "100644" or kind != "blob" or tree_blob != expected_blob: + raise ValueError(f"Git tree path does not bind the expected regular source blob: {source_path}") + return contents, manifest_files, tree_objects, manifest_trees + + +def create_bundle(repository: Path, commit: str) -> bytes: + contents, manifest_files, tree_objects, manifest_trees = read_commit_files(repository, commit) + manifest = json.dumps({ + "schema_version": 1, + "commit": commit, + "files": manifest_files, + "trees": manifest_trees, + }, sort_keys=True, separators=(",", ":")).encode("ascii") + b"\n" + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle: + members = [("manifest.json", manifest)] + members.extend((f"files/{key}", value) for key, value in sorted(contents.items())) + members.extend(sorted(tree_objects.items())) + for name, data in members: + entry = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0)) + entry.compress_type = zipfile.ZIP_STORED + entry.external_attr = 0o100600 << 16 + bundle.writestr(entry, data) + result = output.getvalue() + if len(result) > MAX_BUNDLE_BYTES: + raise ValueError("receiver bundle exceeds its size limit") + verify_bundle(result, commit, hashlib.sha256(result).hexdigest()) + return result + + +def write_bundle(repository: Path, commit: str, output_path: Path) -> str: + if output_path.exists() or output_path.is_symlink(): + raise ValueError("refusing to overwrite an existing release bundle") + if not output_path.parent.is_dir() or output_path.parent.is_symlink(): + raise ValueError("release bundle output directory must already exist and be a real directory") + payload = create_bundle(repository, commit) + with output_path.open("xb") as stream: + stream.write(payload) + stream.flush() + return hashlib.sha256(payload).hexdigest() + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", type=Path, required=True) + parser.add_argument("--commit", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + digest = write_bundle(args.repository, args.commit, args.output) + print(f"Prepared receiver bundle for {args.commit}") + print(f"Bundle SHA-256: {digest}") + print(f"Bundle path: {args.output.resolve()}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/infra/deploy/release_source.py b/infra/deploy/release_source.py index bdb6d7d..b1011ac 100644 --- a/infra/deploy/release_source.py +++ b/infra/deploy/release_source.py @@ -2,6 +2,7 @@ from __future__ import annotations +import base64 import hashlib import json import os @@ -26,6 +27,12 @@ MAX_API_RESPONSE_BYTES = 1024 * 1024 MAX_API_DOWNLOAD_BYTES = 10 * 1024 * 1024 MAX_CANDIDATE_RELEASE_RUNS = 10 API_TIMEOUT_SECONDS = 15 +RUNTIME_FILES = ( + "deploy/production/compose.yaml", + "deploy/production/Caddyfile", + "infra/deploy/release-policy.json", +) +MAX_RUNTIME_FILE_BYTES = 256 * 1024 def run_api_helper(command: list[str], *, env: dict | None, timeout: int, @@ -85,6 +92,58 @@ def api_json(path: str, *, helper: str = "gitea-api", env: dict | None = None) - return value +def repository_file(path: str, commit: str, *, api=api_json) -> bytes: + """Fetch one small, pinned repository file and verify Gitea's git blob identity.""" + if not re.fullmatch(r"[0-9a-f]{40}", commit) or path not in RUNTIME_FILES: + raise ValueError("runtime file request is not allowlisted or commit-pinned") + query = urlencode({"ref": commit}) + value = api(f"/repos/{REPOSITORY}/contents/{path}?{query}") + if (value.get("type") != "file" or value.get("path") != path + or value.get("encoding") != "base64"): + raise ValueError("Gitea runtime file metadata is invalid") + encoded, blob_sha, size = value.get("content"), value.get("sha"), value.get("size") + if (not isinstance(encoded, str) or not isinstance(blob_sha, str) + or not re.fullmatch(r"[0-9a-f]{40}", blob_sha) + or isinstance(size, bool) or not isinstance(size, int) + or size < 0 or size > MAX_RUNTIME_FILE_BYTES): + raise ValueError("Gitea runtime file exceeds its limit or has invalid metadata") + try: + content = base64.b64decode(encoded, validate=True) + except (ValueError, base64.binascii.Error) as error: + raise ValueError("Gitea runtime file content is invalid base64") from error + git_blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode("ascii") + b"\0" + content).hexdigest() + if len(content) != size or git_blob_sha != blob_sha: + raise ValueError("Gitea runtime file content does not match its pinned git blob") + return content + + +def runtime_compatibility_reason(commit: str, *, checkout: Path, policy_file: Path, + api=api_json) -> str | None: + """Require the bootstrap host's fixed runtime/configuration to match target source.""" + for relative in RUNTIME_FILES: + local_path = policy_file if relative == "infra/deploy/release-policy.json" else checkout / relative + try: + info = local_path.lstat() + if not stat.S_ISREG(info.st_mode): + raise ValueError(f"protected runtime file is not a regular file: {relative}") + local_content = local_path.read_bytes() + except OSError as error: + raise ValueError(f"protected runtime file is unavailable: {relative}") from error + remote_content = repository_file(relative, commit, api=api) + if local_content != remote_content: + return "release_policy_changed" if relative.endswith("release-policy.json") else "runtime_config_changed" + return None + + +def require_runtime_compatibility(commit: str, *, checkout: Path, policy_file: Path, + api=api_json) -> None: + reason = runtime_compatibility_reason( + commit, checkout=checkout, policy_file=policy_file, api=api, + ) + if reason is not None: + raise ValueError(f"host {reason.replace('_', ' ')}; reviewed bootstrap update is required") + + def latest_release_request(*, current_main: str, api=api_json, checkout: Path | None = None) -> dict | None: if not re.fullmatch(r"[0-9a-f]{40}", current_main): diff --git a/infra/deploy/test_deploy_release.py b/infra/deploy/test_deploy_release.py index b5717ec..1847058 100644 --- a/infra/deploy/test_deploy_release.py +++ b/infra/deploy/test_deploy_release.py @@ -87,6 +87,59 @@ class DeploymentCutoverTests(unittest.TestCase): run_mock.assert_not_called() compose_mock.assert_not_called() + def test_main_advance_after_backup_aborts_before_image_reference_switch(self): + commit = "b" * 40 + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "deploy/production").mkdir(parents=True) + for name, payload in (("compose.yaml", b"compose\n"), ("Caddyfile", b"caddy\n")): + (root / "deploy/production" / name).write_bytes(payload) + from hashlib import sha256 + deployment_files = { + f"deploy/production/{name}": sha256((root / "deploy/production" / name).read_bytes()).hexdigest() + for name in ("compose.yaml", "Caddyfile") + } + backup = {"images": self.current, "deployment_files": deployment_files} + backup_identity = { + "backup_id": "20261103T000000Z-" + "a" * 32, + "size": 100, "archive_sha256": "c" * 64, + "manifest_sha256": "d" * 64, "manifest": backup, + } + manifest = { + "commit": commit, + "builder": {"name": "Kaniko", "image": deploy_release.BUILDER_IMAGE}, + "images": {service: f"registry/{service}@sha256:{'e' * 64}" for service in ("backend", "opensearch")}, + } + journal = {"status": "external-backup-verified"} + with ( + patch.object(deploy_release.os, "geteuid", return_value=0), + patch.object(deploy_release, "ROOT", root), + patch.object(deploy_release, "STATE_FILE", root / "state.json"), + patch.object(deploy_release, "JOURNAL_FILE", root / "journal.json"), + patch.object(deploy_release, "validate_manifest", return_value=manifest), + patch("release_state.validate_backup_identity", return_value=backup_identity), + patch("release_handoff.latest_main_sha", side_effect=(commit, "f" * 40)), + patch("release_source.require_runtime_compatibility"), + patch.object(deploy_release, "image_ids", return_value=self.current), + patch.object(deploy_release, "app_images", return_value=self.current), + patch.object(deploy_release, "read_json", return_value=journal), + patch.object(deploy_release, "write_json_atomic"), + patch.object(deploy_release, "run", side_effect=( + "", "sha256:" + "1" * 64, "", "sha256:" + "2" * 64, + "sha256:" + "1" * 64, "sha256:" + "2" * 64, + )), + patch.object(deploy_release, "replace_images") as replace, + patch.object(deploy_release, "restore_previous_references"), + patch.object(deploy_release, "compose"), + patch.object(deploy_release, "write_deployed_backend_image"), + ): + with self.assertRaisesRegex(ValueError, "cutover failed"): + deploy_release.deploy_external( + manifest, {}, backup, backup_identity, "9" * 64, + self.current, "ru", "ky", + ) + replace.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/infra/deploy/test_external_deploy.py b/infra/deploy/test_external_deploy.py index e9b7f82..56e8a58 100644 --- a/infra/deploy/test_external_deploy.py +++ b/infra/deploy/test_external_deploy.py @@ -28,7 +28,7 @@ def release_state(commit: str, descriptor: str) -> dict: class ExternalDeployTests(unittest.TestCase): - def run_execute(self, root: Path, state: dict, *, compose_mock=None): + def run_execute(self, root: Path, state: dict, *, compose_mock=None, compatibility_error=None): compose_mock = compose_mock or Mock() state_file = root / "state.json" state_file.write_text("{}", encoding="utf-8") @@ -43,6 +43,7 @@ class ExternalDeployTests(unittest.TestCase): patch.object(external_deploy, "JOURNAL_FILE", journal), patch.object(external_deploy, "STATE_FILE", state_file), patch.object(external_deploy, "fetch_release", return_value=({"commit": COMMIT}, b"")), + patch.object(external_deploy, "require_runtime_compatibility", side_effect=compatibility_error), patch.object(external_deploy, "deployment_lock", return_value=nullcontext()), patch.object(external_deploy, "image_ids", return_value=CURRENT), patch.object(external_deploy, "deploy_release_revision", return_value=COMMIT), @@ -73,6 +74,30 @@ class ExternalDeployTests(unittest.TestCase): self.assertEqual(journal["status"], "external-backup-pending") self.assertEqual(journal["commit"], COMMIT) + def test_runtime_mismatch_fails_before_journal_or_production_operations(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + compose = Mock() + with self.assertRaisesRegex(ValueError, "runtime config mismatch"): + self.run_execute( + root, release_state("b" * 40, "e" * 64), compose_mock=compose, + compatibility_error=ValueError("runtime config mismatch"), + ) + self.assertFalse((root / "journal.json").exists()) + compose.assert_not_called() + + def test_runtime_change_after_initial_check_fails_inside_lock_before_stop(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + compose = Mock() + with self.assertRaisesRegex(ValueError, "changed after status"): + self.run_execute( + root, release_state("b" * 40, "e" * 64), compose_mock=compose, + compatibility_error=[None, ValueError("runtime changed after status")], + ) + self.assertFalse((root / "journal.json").exists()) + compose.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/infra/deploy/test_external_status.py b/infra/deploy/test_external_status.py index ddb64e0..b89c7e4 100644 --- a/infra/deploy/test_external_status.py +++ b/infra/deploy/test_external_status.py @@ -8,7 +8,7 @@ import external_status class ExternalStatusTests(unittest.TestCase): - def test_status_is_candidate_only_after_release_descriptor_verification(self): + def test_status_is_prepared_only_after_release_descriptor_verification(self): commit = "a" * 40 request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64} with tempfile.TemporaryDirectory() as directory: @@ -19,15 +19,19 @@ class ExternalStatusTests(unittest.TestCase): policy.write_text("{}", encoding="utf-8") with patch.object(external_status.os, "geteuid", return_value=0), \ patch.object(external_status, "latest_main_sha", return_value=commit), \ + patch.object(external_status, "runtime_compatibility_reason", return_value=None) as compatible, \ patch.object(external_status, "latest_release_request", return_value=request) as select, \ patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify: value = external_status.status_document( api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy, state_file=state, main_lookup=lambda _path: commit, - checkout_lookup=lambda _path: commit, ) - self.assertEqual(value["state"], "candidate") + self.assertEqual(value["state"], "prepared") self.assertEqual(value["release"], request) + compatible.assert_called_once() + self.assertEqual(compatible.call_args.args, (commit,)) + self.assertEqual(compatible.call_args.kwargs["checkout"], root) + self.assertEqual(compatible.call_args.kwargs["policy_file"], policy) select.assert_called_once_with(current_main=commit, checkout=root, api=ANY) verify.assert_called_once() @@ -46,17 +50,38 @@ class ExternalStatusTests(unittest.TestCase): "current": images, "previous": images, }), encoding="utf-8") with patch.object(external_status.os, "geteuid", return_value=0), \ + patch.object(external_status, "runtime_compatibility_reason", return_value=None), \ patch.object(external_status, "latest_release_request", return_value=request), \ patch.object(external_status, "fetch_release") as verify: result = external_status.status_document( api=lambda _path: {}, checkout=root, journal=journal, policy_file=policy, state_file=state, main_lookup=lambda _path: commit, - checkout_lookup=lambda _path: commit, ) self.assertEqual(result["reason"], "already_applied") verify.assert_not_called() - def test_status_fails_closed_for_root_guard_recovery_and_stale_checkout(self): + def test_status_does_not_publish_prepared_tuple_if_main_advances_during_verification(self): + commit = "a" * 40 + request = {"run_id": "302", "commit": commit, "descriptor_sha256": "b" * 64} + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + policy = root / "policy.json" + policy.write_text("{}", encoding="utf-8") + with patch.object(external_status.os, "geteuid", return_value=0), \ + patch.object(external_status, "runtime_compatibility_reason", return_value=None), \ + patch.object(external_status, "latest_release_request", return_value=request), \ + patch.object(external_status, "fetch_release", return_value=({"commit": commit, "run_id": "302"}, b"descriptor")) as verify: + mains = iter((commit, "c" * 40)) + result = external_status.status_document( + checkout=root, journal=root / "journal", policy_file=policy, + state_file=root / "state.json", main_lookup=lambda _path: next(mains), + ) + self.assertEqual(result, { + "schema_version": 1, "state": "not_ready", "reason": "main_advanced", "release": None, + }) + verify.assert_called_once() + + def test_status_fails_closed_for_root_guard_recovery_and_runtime_mismatch(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory) journal = root / "recovery.json" @@ -70,11 +95,15 @@ class ExternalStatusTests(unittest.TestCase): value = external_status.status_document(checkout=root, journal=journal, policy_file=policy) self.assertEqual(value["reason"], "recovery_journal") journal.unlink() + with patch.object(external_status.os, "geteuid", return_value=0), \ + patch.object(external_status, "runtime_compatibility_reason", return_value="runtime_config_changed"), \ + patch.object(external_status, "latest_release_request") as select: value = external_status.status_document( checkout=root, journal=journal, policy_file=policy, - main_lookup=lambda _path: "a" * 40, checkout_lookup=lambda _path: "c" * 40, + main_lookup=lambda _path: "a" * 40, ) - self.assertEqual(value["reason"], "host_checkout_stale") + self.assertEqual(value["reason"], "runtime_config_changed") + select.assert_not_called() if __name__ == "__main__": diff --git a/infra/deploy/test_release_source.py b/infra/deploy/test_release_source.py index bcfc525..aef0058 100644 --- a/infra/deploy/test_release_source.py +++ b/infra/deploy/test_release_source.py @@ -1,4 +1,5 @@ import hashlib +import base64 import json import stat import sys @@ -49,6 +50,84 @@ def write_artifact(path, manifest=MANIFEST, extra=()): class ReleaseSourceTests(unittest.TestCase): + def test_runtime_files_allow_bootstrap_a_to_release_b_only_when_configs_match(self): + commit_b = "f" * 40 + contents = { + "deploy/production/compose.yaml": b"compose-v1\n", + "deploy/production/Caddyfile": b"caddy-v1\n", + "infra/deploy/release-policy.json": b"policy-v1\n", + } + + def response(path): + payload = contents[path] + blob_sha = hashlib.sha1(b"blob " + str(len(payload)).encode() + b"\0" + payload).hexdigest() + return {"type": "file", "path": path, "encoding": "base64", + "content": base64.b64encode(payload).decode(), "size": len(payload), "sha": blob_sha} + + def api(request): + self.assertIn(f"?ref={commit_b}", request) + path = request.split("/contents/", 1)[1].split("?", 1)[0] + return response(path) + + with tempfile.TemporaryDirectory() as directory: + checkout = Path(directory) + (checkout / "deploy/production").mkdir(parents=True) + (checkout / "deploy/production/compose.yaml").write_bytes(contents["deploy/production/compose.yaml"]) + (checkout / "deploy/production/Caddyfile").write_bytes(contents["deploy/production/Caddyfile"]) + policy = checkout / "policy.json" + policy.write_bytes(contents["infra/deploy/release-policy.json"]) + self.assertIsNone(release_source.runtime_compatibility_reason( + commit_b, checkout=checkout, policy_file=policy, api=api, + )) + + for relative in ("deploy/production/compose.yaml", "deploy/production/Caddyfile"): + local_path = checkout / relative + expected = contents[relative] + local_path.write_bytes(b"changed\n") + self.assertEqual(release_source.runtime_compatibility_reason( + commit_b, checkout=checkout, policy_file=policy, api=api, + ), "runtime_config_changed") + local_path.write_bytes(expected) + policy.write_bytes(b"policy-changed\n") + self.assertEqual(release_source.runtime_compatibility_reason( + commit_b, checkout=checkout, policy_file=policy, api=api, + ), "release_policy_changed") + + def test_runtime_file_rejects_forged_git_blob_identity(self): + payload = b"trusted content\n" + response = {"type": "file", "path": "deploy/production/compose.yaml", "encoding": "base64", + "content": base64.b64encode(payload).decode(), "size": len(payload), "sha": "a" * 40} + with self.assertRaisesRegex(ValueError, "does not match"): + release_source.repository_file( + "deploy/production/compose.yaml", COMMIT, + api=lambda _path: response, + ) + + def test_runtime_compatibility_rejects_local_symlink_even_when_bytes_match(self): + content = b"protected\n" + with tempfile.TemporaryDirectory() as directory: + checkout = Path(directory) + config_dir = checkout / "deploy/production" + config_dir.mkdir(parents=True) + (config_dir / "compose.yaml").write_bytes(content) + (config_dir / "Caddyfile").write_bytes(content) + policy = checkout / "policy.json" + policy.write_bytes(content) + external = checkout / "outside.yaml" + external.write_bytes(content) + (config_dir / "Caddyfile").unlink() + (config_dir / "Caddyfile").symlink_to(external) + blob_sha = hashlib.sha1(b"blob " + str(len(content)).encode() + b"\0" + content).hexdigest() + with self.assertRaisesRegex(ValueError, "not a regular file"): + release_source.runtime_compatibility_reason( + COMMIT, checkout=checkout, policy_file=policy, + api=lambda _path: { + "type": "file", "path": "deploy/production/compose.yaml", + "encoding": "base64", "content": base64.b64encode(content).decode(), + "size": len(content), "sha": blob_sha, + }, + ) + def test_verify_run_binds_run_id_workflow_path_event_branch_commit_and_main(self): def api(path): if path.endswith("/actions/runs/301"): diff --git a/infra/deploy/test_windows_release_bundle_mode.ps1 b/infra/deploy/test_windows_release_bundle_mode.ps1 new file mode 100644 index 0000000..3d7a685 --- /dev/null +++ b/infra/deploy/test_windows_release_bundle_mode.ps1 @@ -0,0 +1,210 @@ +param( + [Parameter(Mandatory = $true)] + [string]$PythonPath +) + +$ErrorActionPreference = 'Stop' +$installerPath = Join-Path $PSScriptRoot 'install_windows_release_client.ps1' +$fixtureScript = Join-Path $PSScriptRoot 'test_windows_release_launcher.py' +$temporaryBase = [IO.Path]::GetFullPath([IO.Path]::GetTempPath()) +$temporaryBase = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $temporaryBase).Path).TrimEnd('\') +$temporaryName = 'akyldash-bundle-mode-' + [guid]::NewGuid().ToString('N') +$temporaryRoot = [IO.Path]::Combine($temporaryBase, $temporaryName) +if (Test-Path -LiteralPath $temporaryRoot) { throw 'Temporary fixture directory already exists.' } +[void][IO.Directory]::CreateDirectory($temporaryRoot) +$temporaryRootItem = Get-Item -LiteralPath $temporaryRoot -Force +if (($temporaryRootItem.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Temporary fixture root must not be a reparse point.' +} +$temporaryRootCreated = $temporaryRootItem.CreationTimeUtc +$temporaryAclOwner = (Get-Acl -LiteralPath $temporaryRoot).Owner +$temporaryOwnerSid = ([Security.Principal.NTAccount]$temporaryAclOwner).Translate( + [Security.Principal.SecurityIdentifier]).Value +$currentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value +if ($temporaryOwnerSid -cne $currentSid) { throw 'Temporary fixture root is not owned by the current user.' } +$bundlePath = Join-Path $temporaryRoot 'fixture.zip' + +function Get-VerifiedFixtureRoot([string]$Candidate) { + $expectedRoot = [IO.Path]::Combine($temporaryBase, $temporaryName) + $candidatePath = [IO.Path]::GetFullPath($Candidate) + if ([string]::Compare($candidatePath, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0) { + throw 'Refusing a fixture root outside the exact temporary base/name.' + } + $item = Get-Item -LiteralPath $candidatePath -Force + if ($item -isnot [IO.DirectoryInfo] -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Fixture root must remain a real directory, not a reparse point.' + } + $resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $candidatePath).Path).TrimEnd('\') + if ([string]::Compare($resolved, $expectedRoot, [StringComparison]::OrdinalIgnoreCase) -ne 0 -or + [string]::Compare([IO.Path]::GetDirectoryName($resolved), $temporaryBase, + [StringComparison]::OrdinalIgnoreCase) -ne 0 -or + [string]::Compare([IO.Path]::GetFileName($resolved), $temporaryName, + [StringComparison]::OrdinalIgnoreCase) -ne 0 -or + $item.CreationTimeUtc -ne $temporaryRootCreated) { + throw 'Fixture root was replaced, moved, or resolved outside its original identity.' + } + $ownerName = (Get-Acl -LiteralPath $candidatePath).Owner + $ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate( + [Security.Principal.SecurityIdentifier]).Value + if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) { + throw 'Fixture root owner changed.' + } + return $resolved +} + +function Get-VerifiedFixtureEntry([string]$Path, [string]$ResolvedRoot) { + $item = Get-Item -LiteralPath $Path -Force + if (($item -isnot [IO.FileInfo] -and $item -isnot [IO.DirectoryInfo]) -or + ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) { + throw 'Fixture entry must be a regular file or directory without reparse points.' + } + $resolved = [IO.Path]::GetFullPath((Resolve-Path -LiteralPath $item.FullName).Path) + if (-not $resolved.StartsWith($ResolvedRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { + throw 'Fixture entry resolved outside its verified temporary root.' + } + $ownerName = (Get-Acl -LiteralPath $resolved).Owner + $ownerSid = ([Security.Principal.NTAccount]$ownerName).Translate( + [Security.Principal.SecurityIdentifier]).Value + if ($ownerSid -cne $temporaryOwnerSid -or $ownerSid -cne $currentSid) { + throw 'Fixture entry is not owned by the current user.' + } + return Get-Item -LiteralPath $resolved -Force +} + +$wrongRootRejected = $false +try { [void](Get-VerifiedFixtureRoot -Candidate $temporaryBase) } +catch { $wrongRootRejected = $true } +if (-not $wrongRootRejected) { throw 'Fixture cleanup accepted an incorrect root path.' } +$passMessage = $null + +try { + $fixtureOutput = & $PythonPath -B -E -s -S $fixtureScript --write-bundle-fixture $bundlePath + if ($LASTEXITCODE -ne 0) { throw 'The temporary packager fixture failed.' } + $fixture = ($fixtureOutput -join "`n") | ConvertFrom-Json + + $gitObjectDirectory = Join-Path $temporaryRoot 'fixture-repository\.git\objects' + $readonlyObject = Get-ChildItem -LiteralPath $gitObjectDirectory -File -Force -Recurse | Select-Object -First 1 + if ($null -eq $readonlyObject) { throw 'Packager fixture did not create a loose Git object.' } + $verifiedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + $verifiedObject = Get-VerifiedFixtureEntry -Path $readonlyObject.FullName -ResolvedRoot $verifiedRoot + [IO.File]::SetAttributes($verifiedObject.FullName, + [IO.FileAttributes]([int]$verifiedObject.Attributes -bor [int][IO.FileAttributes]::ReadOnly)) + + $tokens = $null + $parseErrors = $null + $installerAst = [System.Management.Automation.Language.Parser]::ParseFile( + $installerPath, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors.Count -gt 0) { throw 'Installer PowerShell syntax is invalid.' } + $validatorAst = $installerAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -ceq 'Assert-ReceiverBundleEntryMode' + }, $true) + if ($null -eq $validatorAst) { throw 'Installer bundle-mode validator was not found.' } + . ([scriptblock]::Create($validatorAst.Extent.Text)) + + Add-Type -AssemblyName System.IO.Compression + $stream = [IO.File]::OpenRead($bundlePath) + try { + $archive = [System.IO.Compression.ZipArchive]::new( + $stream, [System.IO.Compression.ZipArchiveMode]::Read, $true) + try { + foreach ($entry in $archive.Entries) { + Assert-ReceiverBundleEntryMode -Entry $entry + } + } finally { $archive.Dispose() } + } finally { $stream.Dispose() } + + $alteredPath = Join-Path $temporaryRoot 'altered-mode.zip' + $sourceStream = [IO.File]::OpenRead($bundlePath) + $targetStream = [IO.File]::Create($alteredPath) + try { + $sourceArchive = [System.IO.Compression.ZipArchive]::new( + $sourceStream, [System.IO.Compression.ZipArchiveMode]::Read, $true) + $targetArchive = [System.IO.Compression.ZipArchive]::new( + $targetStream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + $changed = $false + foreach ($entry in $sourceArchive.Entries) { + $copy = $targetArchive.CreateEntry($entry.FullName, [System.IO.Compression.CompressionLevel]::NoCompression) + $copy.ExternalAttributes = $entry.ExternalAttributes + if (-not $changed) { + $copy.ExternalAttributes = [int]-2120220672 + $changed = $true + } + $entryInput = $entry.Open() + $entryOutput = $copy.Open() + try { $entryInput.CopyTo($entryOutput) } + finally { $entryOutput.Dispose(); $entryInput.Dispose() } + } + if (-not $changed) { throw 'Packager fixture had no entries to mutate.' } + } finally { $targetArchive.Dispose(); $sourceArchive.Dispose() } + } finally { $targetStream.Dispose(); $sourceStream.Dispose() } + + $alteredStream = [IO.File]::OpenRead($alteredPath) + try { + $alteredArchive = [System.IO.Compression.ZipArchive]::new( + $alteredStream, [System.IO.Compression.ZipArchiveMode]::Read, $true) + try { + $alteredEntry = $alteredArchive.Entries[0] + $alteredMode = (([long]$alteredEntry.ExternalAttributes -shr 16) -band 0xFFFF) + if ($alteredMode -ne 0x81A0) { throw 'Altered ZIP fixture does not contain the intended unsafe mode.' } + $rejected = $false + try { Assert-ReceiverBundleEntryMode -Entry $alteredEntry } + catch { + if ($_.Exception.Message -notmatch 'unsafe file mode') { throw } + $rejected = $true + } + if (-not $rejected) { throw 'Installer validator accepted an altered ZIP entry mode.' } + } finally { $alteredArchive.Dispose() } + } finally { $alteredStream.Dispose() } + + $passMessage = ("PASS: installer validator accepted the actual packager bundle for commit {0} (SHA-256 {1}) and rejected altered mode; read-only Git object cleanup succeeded." -f $fixture.commit, $fixture.sha256) +} finally { + $resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + $entries = @(Get-ChildItem -LiteralPath $resolvedRoot -Force -Recurse) + $verifiedEntries = @( + foreach ($entry in $entries) { + Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot + } + ) + foreach ($entry in ($verifiedEntries | Sort-Object { $_.FullName.Length } -Descending)) { + $resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + $verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot + if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + $writableAttributes = [IO.FileAttributes]([int]$verified.Attributes -band + (-bnot [int][IO.FileAttributes]::ReadOnly)) + [IO.File]::SetAttributes($verified.FullName, $writableAttributes) + $verified = Get-VerifiedFixtureEntry -Path $verified.FullName -ResolvedRoot $resolvedRoot + if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + throw 'Could not clear the read-only attribute from the verified fixture entry.' + } + } + } + $remainingRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + $remainingEntries = @(Get-ChildItem -LiteralPath $remainingRoot -Force -Recurse) + $initialPaths = @($entries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object) + $remainingPaths = @($remainingEntries | ForEach-Object { [IO.Path]::GetFullPath($_.FullName) } | Sort-Object) + if (($initialPaths -join "`n") -cne ($remainingPaths -join "`n")) { + throw 'Temporary fixture contents changed during cleanup; refusing deletion.' + } + foreach ($entry in ($remainingEntries | Sort-Object { $_.FullName.Length } -Descending)) { + $resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + $verified = Get-VerifiedFixtureEntry -Path $entry.FullName -ResolvedRoot $resolvedRoot + if (($verified.Attributes -band [IO.FileAttributes]::ReadOnly) -ne 0) { + throw 'Refusing fixture deletion while a verified entry remains read-only.' + } + if ($verified -is [IO.DirectoryInfo]) { + [IO.Directory]::Delete($verified.FullName, $false) + } elseif ($verified -is [IO.FileInfo]) { + [IO.File]::Delete($verified.FullName) + } else { + throw 'Refusing to delete an unknown temporary fixture entry type.' + } + } + $resolvedRoot = Get-VerifiedFixtureRoot -Candidate $temporaryRoot + [IO.Directory]::Delete($resolvedRoot, $false) +} + +Write-Output $passMessage diff --git a/infra/deploy/test_windows_release_launcher.py b/infra/deploy/test_windows_release_launcher.py new file mode 100644 index 0000000..cebdc12 --- /dev/null +++ b/infra/deploy/test_windows_release_launcher.py @@ -0,0 +1,402 @@ +import os +import re +import shlex +import subprocess +import sys +import tempfile +import unittest +import xml.etree.ElementTree as ET +import zipfile +import io +import hashlib +import json +from pathlib import Path + +import prepare_windows_release_bundle as release_bundle +from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment + +ROOT = Path(__file__).resolve().parents[2] + + +class WindowsReleaseBootstrapTests(unittest.TestCase): + def _installer_mutation_mask(self): + script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8") + match = re.search(r"\$mutationRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S) + self.assertIsNotNone(match, "installer must declare one explicit FileSystemRights mutation mask") + names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1))) + values = { + "WriteData": 0x0002, + "AppendData": 0x0004, + "WriteExtendedAttributes": 0x0010, + "WriteAttributes": 0x0100, + "DeleteSubdirectoriesAndFiles": 0x0040, + "Delete": 0x10000, + "ChangePermissions": 0x40000, + "TakeOwnership": 0x80000, + } + self.assertEqual(names, set(values), "mutation mask must name each mutating filesystem right explicitly") + mask = 0 + for name in names: + mask |= values[name] + return script, mask, values + + def test_installer_acl_guard_rejects_each_mutation_right(self): + script, mask, rights = self._installer_mutation_mask() + trusted = {"S-1-5-18", "S-1-5-32-544", "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"} + + def safe_acl(owner, entries): + if owner not in trusted: + return False + for sid, access_type, granted, inherit_only in entries: + if inherit_only: + continue + if access_type == "Allow" and granted & mask and sid not in trusted: + return False + return True + + for name, right in rights.items(): + with self.subTest(right=name): + self.assertNotEqual(mask & right, 0) + self.assertFalse(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", right, False)])) + self.assertTrue(safe_acl( + "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464", + [("S-1-5-32-544", "Allow", mask, False), ("S-1-5-11", "Allow", 0x0001, False)], + )) + self.assertTrue(safe_acl("S-1-5-18", [("S-1-5-11", "Allow", mask, True)])) + self.assertIn("([int]$_.FileSystemRights -band [int]$mutationRights)", script) + self.assertIn("([int]$rule.FileSystemRights -band [int]$checkedRights)", script) + self.assertIn("PropagationFlags]::InheritOnly", script) + owner_branch = re.search( + r"if \(\$AllowOwnerWrite\) \{\s*\$approvedOwners = @\((.*?)\)\s*\} else \{\s*\$approvedOwners = @\((.*?)\)\s*\}", + script, re.S, + ) + self.assertIsNotNone(owner_branch) + self.assertIn("$identity.User.Value", owner_branch.group(1)) + self.assertNotIn("$identity.User.Value", owner_branch.group(2)) + self.assertIn("$trustedInstaller", owner_branch.group(2)) + + def test_parent_path_guard_allows_create_sibling_but_rejects_replacement_rights(self): + script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8") + match = re.search(r"\$pathReplacementRights\s*=\s*\[System\.Security\.AccessControl\.FileSystemRights\]\((.*?)\n\)", script, re.S) + self.assertIsNotNone(match) + names = set(re.findall(r"FileSystemRights\]::([A-Za-z]+)", match.group(1))) + values = {"DeleteSubdirectoriesAndFiles": 0x40, "Delete": 0x10000, + "ChangePermissions": 0x40000, "TakeOwnership": 0x80000} + self.assertEqual(names, set(values)) + mask = sum(values.values()) + self.assertEqual(mask & 0x0004, 0) # AppendData/CreateDirectories on C:\ creates a sibling. + for right in values.values(): + self.assertNotEqual(mask & right, 0) + + def test_installer_source_is_hash_bound_locked_and_never_runs_git(self): + script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8") + self.assertIn("[IO.FileShare]::Read", script) + self.assertIn("[System.IO.Compression.ZipArchive]::new($bundleStream", script) + self.assertIn("Assert-ReceiverBundleEntryMode -Entry $zipEntry", script) + self.assertIn("$entryMode -ne 0x8180", script) + self.assertIn("$bundleHash -ne $ExpectedBundleSha256", script) + self.assertIn("manifest.commit -cne $ExpectedCommit", script) + self.assertIn("Get-GitObjectHex -Kind 'blob' -Bytes $data", script) + self.assertIn("Get-GitObjectHex -Kind 'commit' -Bytes $commitBytes", script) + self.assertIn("Get-GitObjectHex -Kind 'tree' -Bytes $treeData", script) + self.assertIn("Receiver bundle commit tree does not contain the exact source file", script) + self.assertIn("objects/commit.bin", script) + self.assertIn("objects/root-tree.bin", script) + self.assertIn("$treeHeaders[0].Groups[1].Value -cne $seenTrees['']", script) + self.assertIn("$verifiedFiles[$file.path] = $data", script) + self.assertNotIn("(& $git", script) + self.assertNotIn("Start-Process", script) + self.assertNotIn("Copy-Item", script) + self.assertLess(script.index("$bundleHash -ne $ExpectedBundleSha256"), + script.index("[IO.File]::WriteAllBytes($destination, $verifiedFiles[$name])")) + self.assertLess(script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }"), + script.index("$bundleStream = [IO.File]::Open")) + self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script) + self.assertIn("$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)", script) + self.assertIn("Assert-TrustedPath $appRoot", script) + self.assertIn("Assert-TrustedPath $programData -ParentsOnly $true", script) + self.assertIn("Set-TrustedCodeOwner $app", script) + self.assertIn("Set-TrustedCodeOwner $destination", script) + for name in release_bundle.FILES: + self.assertIn(f"files/{name}", script) + + def test_release_bundle_uses_exact_commit_objects_not_dirty_worktree_or_git_hooks(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + repository = root / "repo" + repository.mkdir() + self._git(repository, "init", "-b", "main") + self._git(repository, "config", "user.name", "Fixture") + self._git(repository, "config", "user.email", "fixture@example.invalid") + for name, relative in release_bundle.FILES.items(): + path = repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"committed:{name}\n".encode()) + self._git(repository, "add", "--", *release_bundle.FILES.values()) + self._git(repository, "commit", "-m", "fixture") + commit = self._git(repository, "rev-parse", "HEAD").decode().strip() + self._git(repository, "switch", "-c", "not-main") + self._git(repository, "commit", "--allow-empty", "-m", "alternate") + alternate = self._git(repository, "rev-parse", "HEAD").decode().strip() + self._git(repository, "switch", "main") + with self.assertRaisesRegex(ValueError, "local main branch tip"): + release_bundle.create_bundle(repository, alternate) + + executable_path = repository / release_bundle.FILES["archive.py"] + self._git(repository, "switch", "-c", "executable-mode") + executable_path.chmod(0o755) + self._git(repository, "add", "--", release_bundle.FILES["archive.py"]) + self._git(repository, "commit", "-m", "executable source") + executable_commit = self._git(repository, "rev-parse", "HEAD").decode().strip() + self._git(repository, "update-ref", "refs/heads/main", executable_commit) + with self.assertRaisesRegex(ValueError, "not a regular Git blob"): + release_bundle.create_bundle(repository, executable_commit) + self._git(repository, "update-ref", "refs/heads/main", commit) + self._git(repository, "switch", "main") + + symlink_path = repository / release_bundle.FILES["archive.py"] + self._git(repository, "switch", "-c", "symlink-source") + symlink_path.unlink() + symlink_path.symlink_to("missing-target") + self._git(repository, "add", "--", release_bundle.FILES["archive.py"]) + self._git(repository, "commit", "-m", "symlink source") + symlink_commit = self._git(repository, "rev-parse", "HEAD").decode().strip() + self._git(repository, "update-ref", "refs/heads/main", symlink_commit) + with self.assertRaisesRegex(ValueError, "not a regular Git blob"): + release_bundle.create_bundle(repository, symlink_commit) + self._git(repository, "update-ref", "refs/heads/main", commit) + self._git(repository, "switch", "main") + + edited = repository / release_bundle.FILES["receive_external.py"] + edited.write_bytes(b"uncommitted attacker-controlled edit\n") + hook_marker = root / "fsmonitor-ran" + hook = root / "fsmonitor.sh" + hook.write_text(f"#!/bin/sh\nprintf ran > {shlex.quote(str(hook_marker))}\n", encoding="utf-8") + hook.chmod(0o700) + self._git(repository, "config", "core.fsmonitor", str(hook)) + + payload = release_bundle.create_bundle(repository, commit) + expected_sha256 = hashlib.sha256(payload).hexdigest() + self.assertEqual(set(release_bundle.verify_bundle(payload, commit, expected_sha256)), + set(release_bundle.FILES)) + self.assertFalse(hook_marker.exists(), "bundle creation must not invoke config-driven fsmonitor hooks") + with zipfile.ZipFile(io.BytesIO(payload)) as bundle: + manifest = json.loads(bundle.read("manifest.json")) + self.assertEqual(manifest["commit"], commit) + self.assertEqual(set(member.filename for member in bundle.infolist()), + {"manifest.json", *(f"files/{name}" for name in release_bundle.FILES), + *release_bundle.TREE_OBJECTS.values(), "objects/commit.bin"}) + self.assertEqual({(member.external_attr >> 16) & 0xFFFF for member in bundle.infolist()}, + {0o100600}) + self.assertEqual(release_bundle._git_object_sha("commit", bundle.read("objects/commit.bin")), commit) + for tree in manifest["trees"]: + object_name = release_bundle.TREE_OBJECTS[tree["path"]] + self.assertEqual(release_bundle._git_object_sha("tree", bundle.read(object_name)), tree["sha1"]) + for name in release_bundle.FILES: + self.assertEqual(bundle.read(f"files/{name}"), f"committed:{name}\n".encode()) + for member in ("objects/commit.bin", "objects/infra-tree.bin", "files/receive_external.py"): + with self.subTest(member=member): + corrupted = self._rewrite_bundle(payload, member, lambda content: content[:-1] + bytes([content[-1] ^ 1])) + with self.assertRaises(ValueError): + release_bundle.verify_bundle(corrupted, commit, hashlib.sha256(corrupted).hexdigest()) + + traversal = self._rewrite_bundle(payload, "../escape", lambda _unused: b"outside") + with self.assertRaisesRegex(ValueError, "unexpected entries"): + release_bundle.verify_bundle(traversal, commit, hashlib.sha256(traversal).hexdigest()) + duplicate = self._rewrite_bundle(payload, "manifest.json", lambda content: content, duplicate=True) + with self.assertRaisesRegex(ValueError, "duplicate"): + release_bundle.verify_bundle(duplicate, commit, hashlib.sha256(duplicate).hexdigest()) + symlink_entry = self._rewrite_bundle(payload, "files/archive.py", lambda content: content, + external_attr=0o120777 << 16) + with self.assertRaisesRegex(ValueError, "unsafe mode"): + release_bundle.verify_bundle(symlink_entry, commit, hashlib.sha256(symlink_entry).hexdigest()) + altered_mode = self._rewrite_bundle(payload, "files/archive.py", lambda content: content, + external_attr=0o100755 << 16) + with self.assertRaisesRegex(ValueError, "unsafe mode"): + release_bundle.verify_bundle(altered_mode, commit, hashlib.sha256(altered_mode).hexdigest()) + with self.assertRaisesRegex(ValueError, "invalid size"): + release_bundle.verify_bundle(b"x" * (release_bundle.MAX_BUNDLE_BYTES + 1), commit, "0" * 64) + + @staticmethod + def _git(repository, *arguments): + return subprocess.run(["git", "-C", str(repository), *arguments], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE).stdout + + @staticmethod + def _rewrite_bundle(payload, target, transform, *, duplicate=False, external_attr=None): + output = io.BytesIO() + with zipfile.ZipFile(io.BytesIO(payload)) as original, zipfile.ZipFile(output, "w") as altered: + for item in original.infolist(): + content = original.read(item) + if item.filename == target: + content = transform(content) + info = zipfile.ZipInfo(item.filename, item.date_time) + info.compress_type = zipfile.ZIP_STORED + info.external_attr = external_attr if item.filename == target and external_attr is not None else item.external_attr + altered.writestr(info, content) + if duplicate and item.filename == target: + altered.writestr(info, content) + if target not in {item.filename for item in original.infolist()}: + info = zipfile.ZipInfo(target) + info.compress_type = zipfile.ZIP_STORED + info.external_attr = 0o100600 << 16 + altered.writestr(info, transform(b"")) + return output.getvalue() + + def test_installer_has_no_child_processes_and_checks_tools_before_bundle_read(self): + script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8") + trusted_tools = script.index("foreach ($tool in @($python, $git, $ssh, $powershell)) { Assert-TrustedPath $tool }") + bundle_open = script.index("$bundleStream = [IO.File]::Open") + self.assertLess(trusted_tools, bundle_open) + self.assertNotIn("(& $git", script) + self.assertNotIn("Start-Process", script) + self.assertNotIn("Process]::Start", script) + self.assertIn("while ($null -ne $current)", script) + self.assertIn("$current = $current.Parent", script) + self.assertIn("$ownerSid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)", script) + + def test_windows_bundle_mode_harness_uses_safe_python_and_validates_cleanup_target(self): + harness = (ROOT / "infra/deploy/test_windows_release_bundle_mode.ps1").read_text(encoding="utf-8") + self.assertIn("-B -E -s -S", harness) + self.assertIn("$item.CreationTimeUtc -ne $temporaryRootCreated", harness) + self.assertIn("$ownerSid -cne $temporaryOwnerSid", harness) + self.assertIn("[IO.FileAttributes]::ReparsePoint", harness) + self.assertIn("[IO.Directory]::Delete($resolvedRoot, $false)", harness) + self.assertNotIn("[IO.Directory]::Delete($resolvedRoot, $true)", harness) + self.assertIn("$wrongRootRejected", harness) + self.assertIn("$readonlyObject", harness) + self.assertIn("-bnot [int][IO.FileAttributes]::ReadOnly", harness) + self.assertIn("[IO.File]::Delete($verified.FullName)", harness) + self.assertIn("-not $resolved.StartsWith($ResolvedRoot + '\\'", harness) + self.assertLess(harness.index("if ($alteredMode -ne 0x81A0)"), + harness.index("Assert-ReceiverBundleEntryMode -Entry $alteredEntry")) + + def test_windows_tool_environment_discards_path_and_git_overrides(self): + environment = { + "PATH": r"C:\Users\attacker\bin", + "PATHEXT": ".COM;.EXE;.BAT", + "PSModulePath": r"C:\Users\attacker\modules", + "GIT_CONFIG_COUNT": "1", + "GIT_CONFIG_KEY_0": "core.sshCommand", + "GIT_CONFIG_VALUE_0": "attacker.exe", + "SAFE_SETTING": "preserved", + } + calls = [] + + def which(command): + calls.append(command) + return str(WINDOWS_TOOLS[command]) + + configure_windows_tool_environment(environ=environment, which=which) + self.assertEqual(environment["PATH"], WINDOWS_PATH) + self.assertEqual(environment["PATHEXT"], ".EXE") + self.assertEqual(environment["SystemRoot"], r"C:\Windows") + self.assertEqual(environment["PSModulePath"], r"C:\Windows\System32\WindowsPowerShell\v1.0\Modules") + self.assertEqual(environment["SAFE_SETTING"], "preserved") + self.assertFalse(any(key.upper().startswith("GIT_") for key in environment)) + self.assertEqual(set(calls), set(WINDOWS_TOOLS)) + + def test_windows_tool_environment_rejects_unexpected_resolution(self): + environment = {"PATH": r"C:\attacker"} + with self.assertRaisesRegex(RuntimeError, "protected path"): + configure_windows_tool_environment(environ=environment, which=lambda _command: r"C:\attacker\ssh.exe") + + def test_launcher_ignores_poisoned_working_directory_and_pythonpath(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + app = root / "Program Files" / "Akyldash" + poison = root / "poison" + app.mkdir(parents=True) + poison.mkdir() + launcher = app / "windows_release_launcher.py" + launcher.write_bytes((ROOT / "infra/deploy/windows_release_launcher.py").read_bytes()) + (app / "receive_external.py").write_text("def main(): return 0\n", encoding="utf-8") + (app / "archive.py").write_text("ORIGIN = __file__\n", encoding="utf-8") + (app / "external_session.py").write_text("ORIGIN = __file__\n", encoding="utf-8") + marker = root / "sitecustomize-ran" + (poison / "json.py").write_text("raise RuntimeError('poison json imported')\n", encoding="utf-8") + (poison / "sitecustomize.py").write_text( + "import os\nfrom pathlib import Path\nPath(os.environ['POISON_MARKER']).touch()\n", + encoding="utf-8", + ) + driver = app / "driver.py" + driver.write_text( + "import sys\n" + "from windows_release_launcher import configure_import_path\n" + "configure_import_path(executable=sys.executable, app_dir=__import__('pathlib').Path(__file__).parent, " + "python_root=__import__('pathlib').Path(sys.prefix), expected_executable=__import__('pathlib').Path(sys.executable), " + "protected_app_root=__import__('pathlib').Path(__file__).parents[1])\n" + "import json, receive_external, archive, external_session\n" + "from pathlib import Path\n" + "assert Path(json.__file__).resolve().is_relative_to(Path(sys.prefix).resolve())\n" + "assert Path(receive_external.__file__).resolve().parent == Path(__file__).parent.resolve()\n" + "assert Path(archive.__file__).resolve().parent == Path(__file__).parent.resolve()\n" + "assert Path(external_session.__file__).resolve().parent == Path(__file__).parent.resolve()\n", + encoding="utf-8", + ) + environment = {**os.environ, "PYTHONPATH": str(poison), "POISON_MARKER": str(marker)} + result = subprocess.run( + [sys.executable, "-E", "-s", "-S", str(driver)], cwd=poison, + env=environment, text=True, capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(marker.exists()) + + def test_disabled_task_uses_pinned_python_and_hardened_launcher(self): + path = ROOT / "infra/deploy/windows-release-trigger.example.xml" + tree = ET.parse(path) + namespace = {"t": "http://schemas.microsoft.com/windows/2004/02/mit/task"} + task = tree.getroot() + self.assertEqual(task.findtext("t:Settings/t:Enabled", namespaces=namespace), "false") + trigger = task.find("t:Triggers/t:CalendarTrigger", namespace) + self.assertEqual(trigger.findtext("t:Enabled", namespaces=namespace), "false") + self.assertEqual(task.findtext("t:Actions/t:Exec/t:Command", namespaces=namespace), r"C:\Python313\python.exe") + arguments = task.findtext("t:Actions/t:Exec/t:Arguments", namespaces=namespace) + self.assertIn("-B -E -s -S", arguments) + self.assertIn("windows_release_launcher.py", arguments) + + def test_installer_never_registers_or_enables_task_and_uses_canonical_trigger(self): + script = (ROOT / "infra/deploy/install_windows_release_client.ps1").read_text(encoding="utf-8") + self.assertIn("release-trigger.json", script) + self.assertIn("release-auto-apply.json", script) + self.assertIn('"enabled":false', script) + self.assertIn("C:\\Python313\\python.exe", script) + self.assertIn("Get-AuthenticodeSignature", script) + self.assertIn(".Status -ne 'Valid'", script) + self.assertIn("client-$ExpectedCommit", script) + self.assertIn("--auto-apply-prepared", script) + self.assertIn("Akyldash-ReleaseAutoApply-disabled.xml", script) + self.assertIn("Assert-TrustedPath $tool", script) + self.assertIn(r"$git = 'C:\Program Files\Git\cmd\git.exe'", script) + self.assertIn(r"$ssh = 'C:\Windows\System32\OpenSSH\ssh.exe'", script) + self.assertIn(r"$powershell = 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'", script) + self.assertIn("$current = $current.Parent", script) + self.assertIn("PropagationFlags]::InheritOnly", script) + self.assertNotIn("Register-ScheduledTask", script) + self.assertNotIn("Enable-ScheduledTask", script) + + +if __name__ == "__main__": + if len(sys.argv) == 3 and sys.argv[1] == "--write-bundle-fixture": + output_path = Path(sys.argv[2]) + repository = output_path.parent / "fixture-repository" + repository.mkdir() + subprocess.run(["git", "-C", str(repository), "init", "-b", "main"], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(["git", "-C", str(repository), "config", "user.name", "Akyldash Test"], check=True) + subprocess.run(["git", "-C", str(repository), "config", "user.email", "test@example.invalid"], check=True) + for name, relative in release_bundle.FILES.items(): + path = repository / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(f"fixture:{name}\n".encode("ascii")) + subprocess.run(["git", "-C", str(repository), "add", "--", *release_bundle.FILES.values()], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + subprocess.run(["git", "-C", str(repository), "commit", "-m", "bundle fixture"], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + commit = subprocess.run(["git", "-C", str(repository), "rev-parse", "HEAD"], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip() + payload = release_bundle.create_bundle(repository, commit) + output_path.write_bytes(payload) + print(json.dumps({"commit": commit, "sha256": hashlib.sha256(payload).hexdigest()})) + else: + unittest.main() diff --git a/infra/deploy/windows-release-auto-apply.example.json b/infra/deploy/windows-release-auto-apply.example.json new file mode 100644 index 0000000..3c88469 --- /dev/null +++ b/infra/deploy/windows-release-auto-apply.example.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "enabled": false +} diff --git a/infra/deploy/windows-release-auto-apply.example.xml b/infra/deploy/windows-release-auto-apply.example.xml new file mode 100644 index 0000000..f659452 --- /dev/null +++ b/infra/deploy/windows-release-auto-apply.example.xml @@ -0,0 +1,45 @@ + + + + Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation. + \Akyldash\ReleaseAutoApply + + + + 2027-01-01T09:00:00 + false + 1 + + PT5M + P1D + false + + + + + + REPLACE_WITH_OWNER_SID + InteractiveToken + LeastPrivilege + + + + IgnoreNew + true + false + true + false + false + false + false + PT0S + 7 + + + + C:\Python313\python.exe + -B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --auto-apply-prepared + C:\Program Files\Akyldash + + + diff --git a/infra/deploy/windows-release-trigger.example.xml b/infra/deploy/windows-release-trigger.example.xml index 10a18f8..78d041f 100644 --- a/infra/deploy/windows-release-trigger.example.xml +++ b/infra/deploy/windows-release-trigger.example.xml @@ -6,7 +6,7 @@ - 2026-11-02T09:00:00 + 2027-01-01T09:00:00 false 1 @@ -37,8 +37,8 @@ - C:\Program Files\Akyldash\python.exe - "C:\Program Files\Akyldash\receive_external.py" --once + C:\Python313\python.exe + -B -E -s -S "C:\Program Files\Akyldash\windows_release_launcher.py" --once C:\Program Files\Akyldash diff --git a/infra/deploy/windows_release_launcher.py b/infra/deploy/windows_release_launcher.py new file mode 100644 index 0000000..aed93a5 --- /dev/null +++ b/infra/deploy/windows_release_launcher.py @@ -0,0 +1,106 @@ +"""Harden the fixed Windows Python process before loading the release receiver.""" + +from __future__ import annotations + +import importlib +import ntpath +import os +import shutil +import sys +from pathlib import Path + +PYTHON_EXE = Path(r"C:\Python313\python.exe") +APP_DIR = Path(__file__).resolve().parent +WINDOWS_TOOLS = { + "ssh.exe": Path(r"C:\Windows\System32\OpenSSH\ssh.exe"), + "git.exe": Path(r"C:\Program Files\Git\cmd\git.exe"), + "powershell.exe": Path(r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"), +} +WINDOWS_PATH = os.pathsep.join(( + r"C:\Windows\System32\OpenSSH", + r"C:\Program Files\Git\cmd", + r"C:\Windows\System32", + r"C:\Windows", +)) + + +def configure_windows_tool_environment(*, environ=None, which=shutil.which, + windows_root: Path = Path(r"C:\Windows")) -> None: + """Remove inherited command lookup and Git overrides before receiver imports.""" + environment = os.environ if environ is None else environ + system_root = str(windows_root) + environment["SystemRoot"] = system_root + environment["PATH"] = WINDOWS_PATH + environment["PATHEXT"] = ".EXE" + environment["PSModulePath"] = ntpath.join( + str(windows_root), "System32", "WindowsPowerShell", "v1.0", "Modules") + for name in tuple(environment): + if name.upper().startswith("GIT_"): + del environment[name] + for command, expected in WINDOWS_TOOLS.items(): + resolved = which(command) + if resolved is None or os.path.normcase(os.path.abspath(resolved)) != os.path.normcase(os.path.abspath(expected)): + raise RuntimeError(f"required Windows tool is missing from its protected path: {command}") + + +def _is_beneath(path: Path, root: Path) -> bool: + try: + path.resolve().relative_to(root.resolve()) + return True + except (OSError, ValueError): + return False + + +def configure_import_path(*, executable: Path, app_dir: Path, python_root: Path, + path_entries: list[str] | None = None, + expected_executable: Path = PYTHON_EXE, + protected_app_root: Path = Path(r"C:\Program Files")) -> None: + """Keep only stdlib paths and the protected app directory; discard CWD/env paths.""" + executable = Path(executable).resolve() + app_dir = Path(app_dir).resolve() + python_root = Path(python_root).resolve() + if os.path.normcase(str(executable)) != os.path.normcase(str(expected_executable.resolve())): + raise RuntimeError("release trigger must use the verified Python 3.13 installation") + if not app_dir.is_dir() or not _is_beneath(app_dir, protected_app_root): + raise RuntimeError("release receiver directory is outside protected Program Files") + if not python_root.is_dir() or not _is_beneath(executable, python_root): + raise RuntimeError("Python executable is outside its protected installation") + entries = sys.path if path_entries is None else path_entries + safe_stdlib = [] + for value in entries: + if not value: + continue + candidate = Path(value) + if _is_beneath(candidate, python_root): + safe_stdlib.append(str(candidate)) + if not safe_stdlib: + raise RuntimeError("Python standard library path is unavailable") + sys.path[:] = safe_stdlib + [str(app_dir)] + + +def _verify_module_origin(module, root: Path) -> None: + origin = getattr(module, "__file__", None) + if not isinstance(origin, str) or not _is_beneath(Path(origin), root): + raise RuntimeError(f"release module was loaded outside its protected directory: {module.__name__}") + + +def main() -> int: + flags = sys.flags + if not (flags.ignore_environment and flags.no_user_site and flags.no_site): + raise RuntimeError("launch Python with -E -s -S to disable environment and site imports") + configure_import_path(executable=Path(sys.executable), app_dir=APP_DIR, + python_root=PYTHON_EXE.parent) + if os.name == "nt": + configure_windows_tool_environment() + for name in ("json", "ssl", "subprocess", "pathlib"): + module = importlib.import_module(name) + _verify_module_origin(module, PYTHON_EXE.parent) + receiver = importlib.import_module("receive_external") + _verify_module_origin(receiver, APP_DIR) + for name in ("archive", "external_session"): + _verify_module_origin(importlib.import_module(name), APP_DIR) + return receiver.main() + + +if __name__ == "__main__": + raise SystemExit(main())