Harden external release preparation workflow
CI / tests-and-images (pull_request) Successful in 2m15s
Runner smoke test / runner-smoke (pull_request) Successful in 3s

This commit is contained in:
codex committed 2026-11-03 09:38:37 +03:00
1 parent f19faf628e
commit 710c483f34
19 files changed
+1145 -154

No files matched your search

+44 -20
View File
@@ -1,28 +1,52 @@
# 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
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.
- The Windows client has disabled-by-default preparation and auto-apply tasks
with a fixed `D:\Dev\akyldash` target and `akyldash_release` alias. The example
config files and Task Scheduler XML are disabled; the templates use an
interactive least-privilege owner, ignore 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.
or trigger action has been installed or exercised. The canonical
`C:\ProgramData\Akyldash\release-trigger.json` path is known, but its
contents/enabled state have not been read. Windows metadata confirms
`C:\Python313\python.exe`, valid Python Software Foundation Authenticode,
and protected interpreter ACLs. Linux fixtures pass for Python import and
command-path poisoning, including inherited `GIT_*` removal and fixed
`ssh.exe`/`git.exe`/`powershell.exe` resolution. The installer also checks
protected ownership/write access across each tool's parent path. These Windows
ACL checks are code-reviewed only; the installer and launcher have not run on
Windows. Production CD remains inactive.
- 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
+87 -45
View File
@@ -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,77 @@ 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
command. The scheduler templates run as the interactive owner at least
privilege, ignore overlapping instances, and never wake the computer. No task
has been registered or enabled by this 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.
The reviewed bootstrap installer requires a clean `main` checkout at an
explicit SHA, verifies the signed `C:\Python313\python.exe` and protected ACLs,
and stages client files under a SHA-versioned Program Files directory without
overwriting an existing version. It preserves an existing canonical
`C:\ProgramData\Akyldash\release-trigger.json`, requires it to remain disabled,
and creates a second disabled auto-apply config only if absent. It writes two
disabled task XML files for owner review and does not register or enable tasks.
The launcher starts Python with `-B -E -s -S`, removes paths outside the standard
library and protected client folder, and checks imported module origins. Before
loading the receiver it replaces inherited command lookup with the fixed Windows,
OpenSSH, and Git directories; it clears inherited `GIT_*` variables and verifies
that `ssh.exe`, `git.exe`, and `powershell.exe` resolve to their fixed paths. The
installer checks those binaries and every parent path for reparse points, trusted
owners, and write access limited to SYSTEM, Administrators, and TrustedInstaller.
Synthetic poisoning tests ran on Linux; the launcher and installer have not
been executed on Windows. Keep both config gates and both tasks disabled until
the owner records the normal backup/restore and production readiness gates.
Do not bind a Gitea workflow or PR runner to this Windows host.
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.
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 +122,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 +191,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
+43 -22
View File
@@ -6,22 +6,33 @@ private keys, secret material, or raw process environments.
## Required before release use
- 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.
- PR #59's disabled one-shot Windows trigger is in `main`; it has not been
installed or exercised. This PR's bootstrap files are also uninstalled.
After review and merge, run `install_windows_release_client.ps1` only from a
clean `main` checkout and provide its exact SHA through `-ExpectedCommit`.
The installer verifies Python's PSF Authenticode signature and protected
ACLs, stages client files in a SHA-versioned Program Files directory without
replacing an existing version, preserves an existing canonical trigger
config only if it is still disabled, and writes two disabled task XML files
for review. It never registers or enables tasks. The canonical
`C:\ProgramData\Akyldash\release-trigger.json` path is known, but its
contents and enabled state have not been read. The launcher uses the
verified `C:\Python313\python.exe` with `-B -E -s -S` and validates module
origins. It also replaces inherited PATH, clears `GIT_*` overrides, and
requires SSH, Git, and PowerShell to resolve to fixed protected binaries; the
installer checks their parent paths for untrusted write access. The prep task
stores a protected `PREPARED` tuple and never deploys.
The separate auto-apply task is also disabled and requires a second config
gate. The `enabled` field in the preparation config alone cannot deploy.
Apply runs only when the fresh root status tuple matches a tuple saved by an
earlier poll; no per-commit owner confirmation is needed after the owner
explicitly activates unattended deployment. Enable the preparation config
and task only after the owner separately approves polling. Enable the
auto-apply config and task only after the owner approves unattended
production deployment and completes every backup/restore readiness gate.
Disabling either auto-apply control stops future automatic deploys; 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
@@ -30,17 +41,27 @@ private keys, secret material, or raw process environments.
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
both local tasks and both config gates remain disabled. This avoids exposing
the Windows host to repository PR workflows. Verify the root status command
is installed from reviewed bootstrap code and its forced-command key is
restricted. The host checkout need not match current `main`: status compares
root-protected Compose/Caddy bytes and the installed release policy with the
exact candidate SHA through Gitea's pinned contents API and verifies Git blob
hashes/sizes. The receiver repeats this before stopping services and cutover
repeats it after backup. A mismatch requires a reviewed owner bootstrap
update; no root checkout/helper code is automatically executed or updated.
Current `main` and CI/release provenance are still rechecked before apply.
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.
retention for the rollback window. The latest read-only Gitea check found
main CI run #706 successful, image release run #708 failed because the
repository `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` secrets are absent,
and status-only handoff run #709 skipped. Configure those repository secrets
through Gitea's secret settings; do not put their values in Git or logs.
- 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
+147 -13
View File
@@ -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")
+84 -13
View File
@@ -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',
)
+12 -5
View File
@@ -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)
+9 -3
View File
@@ -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))
+9 -22
View File
@@ -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")},
}
@@ -0,0 +1,178 @@
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^[0-9a-f]{40}$')]
[string]$ExpectedCommit
)
$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'
$taskTemplate = Join-Path $PSScriptRoot 'windows-release-trigger.example.xml'
$repo = (& $git -C $PSScriptRoot rev-parse --show-toplevel).Trim()
if ($LASTEXITCODE -ne 0) { throw 'Cannot locate the reviewed repository checkout.' }
$branch = (& $git -C $repo branch --show-current).Trim()
$head = (& $git -C $repo rev-parse HEAD).Trim()
$dirty = & $git -C $repo status --porcelain
if ($LASTEXITCODE -ne 0 -or $branch -ne 'main' -or $head -ne $ExpectedCommit -or $dirty) {
throw 'Install source must be the clean main checkout at ExpectedCommit.'
}
$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
$approvedOwners = @('S-1-5-18', 'S-1-5-32-544', $identity.User.Value)
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
($_.FileSystemRights.ToString() -match 'Write|Modify|FullControl') -and
$_.IdentityReference.Translate([Security.Principal.SecurityIdentifier]).Value -notin @('S-1-5-18', 'S-1-5-32-544')
})
if ($writableUntrusted.Count -gt 0) { throw "Protected path grants write access outside SYSTEM/Administrators: $Path" }
}
}
function Assert-TrustedExecutable([string]$Path) {
$item = Get-Item -LiteralPath $Path -Force
if ($item -isnot [IO.FileInfo] -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Refusing missing or reparse executable: $Path"
}
$trustedInstaller = 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'
$current = $item
while ($null -ne $current) {
if (($current.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Executable 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 "Executable path has an untrusted owner: $($current.FullName)"
}
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
($rule.FileSystemRights.ToString() -match 'Write|Modify|FullControl') -and
$sid -notin @('S-1-5-18', 'S-1-5-32-544', $trustedInstaller)) {
throw "Executable path grants write access outside SYSTEM/Administrators/TrustedInstaller: $($current.FullName)"
}
}
$current = $current.Parent
}
}
foreach ($tool in @($git, $ssh, $powershell)) { Assert-TrustedExecutable $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-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
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.'
}
}
$paths = @(
@{ Source = (Join-Path $repo 'infra\backup\receive_external.py'); Name = 'receive_external.py' },
@{ Source = (Join-Path $repo 'infra\backup\archive.py'); Name = 'archive.py' },
@{ Source = (Join-Path $repo 'infra\backup\external_session.py'); Name = 'external_session.py' },
@{ Source = (Join-Path $repo 'infra\deploy\windows_release_launcher.py'); Name = 'windows_release_launcher.py' }
)
foreach ($entry in $paths) {
if (-not (Test-Path -LiteralPath $entry.Source -PathType Leaf)) { throw "Bootstrap source is missing: $($entry.Name)" }
}
$null = New-Item -ItemType Directory -Path $app
Assert-ProtectedPath $app
foreach ($entry in $paths) {
Copy-Item -LiteralPath $entry.Source -Destination (Join-Path $app $entry.Name)
Assert-ProtectedPath (Join-Path $app $entry.Name)
}
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](Get-Content -LiteralPath $taskTemplate -Raw)
$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](Get-Content -LiteralPath $taskTemplate -Raw)
$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 and task template for reviewed main commit $ExpectedCommit."
Write-Output 'No task was registered or enabled; both configuration gates remain disabled.'
+59
View File
@@ -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):
+53
View File
@@ -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()
+26 -1
View File
@@ -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()
+36 -7
View File
@@ -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__":
+79
View File
@@ -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"):
@@ -0,0 +1,121 @@
import os
import subprocess
import sys
import tempfile
import unittest
import xml.etree.ElementTree as ET
from pathlib import Path
from windows_release_launcher import WINDOWS_PATH, WINDOWS_TOOLS, configure_windows_tool_environment
ROOT = Path(__file__).resolve().parents[2]
class WindowsReleaseBootstrapTests(unittest.TestCase):
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-TrustedExecutable $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__":
unittest.main()
@@ -0,0 +1,4 @@
{
"schema_version": 1,
"enabled": false
}
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Description>Apply only a previously prepared Akyldash release; disabled template requiring explicit owner activation.</Description>
<URI>\Akyldash\ReleaseAutoApply</URI>
</RegistrationInfo>
<Triggers>
<CalendarTrigger>
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
<Enabled>false</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
<Repetition>
<Interval>PT5M</Interval>
<Duration>P1D</Duration>
<StopAtDurationEnd>false</StopAtDurationEnd>
</Repetition>
</CalendarTrigger>
</Triggers>
<Principals>
<Principal id="Owner">
<UserId>REPLACE_WITH_OWNER_SID</UserId>
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>true</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<Enabled>false</Enabled>
<Hidden>false</Hidden>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Owner">
<Exec>
<Command>C:\Python313\python.exe</Command>
<Arguments>-B -E -s -S &quot;C:\Program Files\Akyldash\windows_release_launcher.py&quot; --auto-apply-prepared</Arguments>
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
</Exec>
</Actions>
</Task>
@@ -6,7 +6,7 @@
</RegistrationInfo>
<Triggers>
<CalendarTrigger>
<StartBoundary>2026-11-02T09:00:00</StartBoundary>
<StartBoundary>2027-01-01T09:00:00</StartBoundary>
<Enabled>false</Enabled>
<ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
<Repetition>
@@ -37,8 +37,8 @@
</Settings>
<Actions Context="Owner">
<Exec>
<Command>C:\Program Files\Akyldash\python.exe</Command>
<Arguments>&quot;C:\Program Files\Akyldash\receive_external.py&quot; --once</Arguments>
<Command>C:\Python313\python.exe</Command>
<Arguments>-B -E -s -S &quot;C:\Program Files\Akyldash\windows_release_launcher.py&quot; --once</Arguments>
<WorkingDirectory>C:\Program Files\Akyldash</WorkingDirectory>
</Exec>
</Actions>
+106
View File
@@ -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())