diff --git a/.github/workflows/fleet-converge.yml b/.github/workflows/fleet-converge.yml index 61720a4bda7..014355dabd8 100644 --- a/.github/workflows/fleet-converge.yml +++ b/.github/workflows/fleet-converge.yml @@ -13,7 +13,7 @@ on: mode: description: plan shows membership hunks; apply executes a prior plan artifact; org_actions_observe validates the org credential and refuses on runner-group drift; org_runner_roster_observe reads the organization's self-hosted runner registrations to exhaustion under the same token and refuses unless the roster is complete; app_control_plane_observe reads the gunbai-ci App registration and webhook config with a short-lived App JWT and refuses on hook-config drift; app_key_version_verify reads the gunbai-ci App private key at the EXACT Secret Manager version named by app_key_version, mints an installation token with it, and refuses unless GitHub accepts it and the key's rotation deadline has not passed -- no add, disable or destroy; microvm_host_converge installs the cited Firecracker release on the selected host and reads the kvm grant back, refusing by name when the grant has not landed (the grant itself is applied by the full-host apply spine); microvm_network_apply stages the slot and host network files the model renders at the named expected_revision as root:root 0600 in a root-only directory over the fleet SSH edge as the host's ADMINISTRATOR, installs them with the modeled operations, reloads networkd, systemd-sysctl and the nft loader unit, and reads the ruleset back -- the job user is granted none of it, because install plus systemctl over content that principal can write is arbitrary root for any pull request; guest_image_observe reports the micro-VM base artifacts and their measured digests on the selected host and builds nothing; guest_image_converge builds the runner guest image there; microvm_boot_probe boots that image and reads its serial console; spark_grants and spark_bootstrap prepare the selected Spark; spark_serving_apply applies the promoted fabric groups' pair vLLM units over the password session, workers before heads; spark_native_serving_apply relaunches group B's native four-rank arm as ONE BOUNDED TRANSACTION -- an all-host preflight that mutates nothing and refuses on any unresolved artifact from an earlier run, the incumbent UNIT preserved (plus a diagnostic inspect of the incumbent container -- the container itself cannot outlive its unit, because these run --rm in the foreground under systemd), the head applied BEFORE the workers so every rank joins the head's new rendezvous store, then a readback of the COMPLETE realization through each container's own inspect -- every field the create spec states, the effective --node-rank off that container's argv, and NCCL's typed transport verdict on that incarnation -- plus the head's front door answering with this arm's served model, and then commit or a rollback of the whole arm -- and it is a separate mode because it reloads a 328 GB arm and nobody should reach it by asking for the pair units; spark_runtime_image_probe pulls the pinned runtime image on the selected Spark and reads its capabilities from inside its own digest, changing nothing; spark_v41_checkpoint_materialize fetches the admitted published DeepSeek V4.1 files onto the selected Group A Spark (about 510 GB; spark_v41_row_store_encode encodes the eight Engram row stores from the verified shards on the selected Group A Spark and reads each store's sha256; spark_v41_row_store_readback reads those stores back at their header, first and last record and every rank seam, with the published source rows at the same rows, and writes nothing; spark_v41_engram_differential compares upstream's Engram lookup kernel with the design-B file-backed lookup over sampled real rows of every row store, byte for byte, and writes nothing; it states the requirement and refuses before fetching when the disk cannot hold it), publishes each only after its sha256 matches the manifest, leaves a present file with the right digest alone and refuses one with the wrong digest, and reads the storage-backed Engram spans from the verified shards; a transfer runs detached and a rerun reattaches; spark_v41_runtime_image_build PRODUCES the DeepSeek V4.1 image on the selected Spark -- it verifies the candidate's three FlashInfer wheels against the digests the candidate keys, converges the patched source tree, builds from it, reads the produced configuration digest back from inside the image through the probe route, and admits that digest against the candidate's own recipe, refusing a digest that does not recompute from it -- and it is a separate mode from the probe because it occupies one host for hours where the probe occupies it for minutes; spark_v41_runtime_image_distribute moves that produced image, named by the configuration digest its production receipt read back, from the host that receipt names to the selected Group A Spark -- save, scp through the executor, load -- after stating its size against every filesystem a copy lands on, leaves a target already holding the digest untouched, refuses a target holding a different image under the tag, and refuses unless the target's image inspect Id reads back as that digest; host_reset_return drives a rostered reset subject through its controller and measures the return FROM A PEER, and takes reset_observer rather than host because the observer must not be the subject; runner_host_file_observe reads the runner teardown drop-in, the needrestart deferral and the loaded teardown on the selected host as its administrator and writes nothing; runner_host_file_converge writes whichever of those two files differs, reloads systemd only if the drop-in changed, and refuses unless the manager then reports the declared teardown; microvm_controller_install writes the microVM slot controller's root-owned release locus (gunbc + sources + Firecracker + jailer) and the gunbc-microvm-slot@ template unit on srv1 and starts no instance; microvm_slot_start starts the shakedown slot's controller unit once on srv1 (the instance is derived from the model, never an input), waits for it bounded by the unit's own stop timeout, and uploads that invocation's controller receipt; microvm_runner_group_ensure (srv1 only) reads the organization's runner groups and, only when the microvm-shakedown group is absent, files ONE operator approval, creates it restricted to the shakedown workflow on the default branch, and refuses unless the readback holds that restriction required: true - options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, microvm_network_apply, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, spark_v41_checkpoint_materialize, spark_v41_row_store_encode, spark_v41_row_store_readback, spark_v41_engram_differential, spark_v41_runtime_image_build, spark_v41_runtime_image_distribute, dashboard_deploy, approval_broker_dark_install, microvm_controller_install, microvm_slot_start, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, approval_device_enrolment_code_issue, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_census_image_publish, host_credential_custody_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe, pair_serving_d0, microvm_runner_group_ensure, gcp_iam_converge] + options: [plan, launch_environment_plan, allocation_store_plan, apply, org_actions_observe, app_control_plane_observe, microvm_host_converge, microvm_network_observe, microvm_network_apply, guest_image_observe, guest_image_converge, microvm_boot_probe, spark_grants, spark_bootstrap, spark_serving_apply, spark_native_serving_apply, spark_runtime_image_probe, spark_v41_checkpoint_materialize, spark_v41_row_store_encode, spark_v41_row_store_readback, spark_v41_engram_differential, spark_v41_runtime_image_build, spark_v41_runtime_image_distribute, dashboard_deploy, approval_broker_dark_install, microvm_controller_install, microvm_slot_start, rlm_launch_deployment_receipt, host_reset_return, runner_host_file_observe, runner_host_file_converge, site_pxe_edge_observe, site_pxe_edge_converge, runner_password_session_tool_converge, r2_mint_preflight, r2_object_write_mint, org_runner_roster_observe, approval_keyring_converge, approval_device_enrolment_code_issue, mtcollins1_boot, mtcollins1_fan_observe, mtcollins1_census_image_publish, host_credential_custody_converge, app_key_version_verify, r2_bucket_ensure, r2_bucket_admin_mint, fabric_writer_identity_observe, pair_serving_d0, microvm_runner_group_ensure, gcp_iam_converge, namecheap_observe] type: choice target: description: "Spark target host for the spark_* modes, naming the administrator credential the run materializes (any rostered Spark: srv5, srv6, srv7, srv8, srv9, srv10, srv11, srv12); apply reads the subject off the plan artifact" @@ -79,7 +79,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Isolate toolchain dirs run: |- rm -rf "$RUNNER_TEMP/rustup" "$RUNNER_TEMP/cargo" @@ -176,7 +176,7 @@ jobs: runs-on: ${{ fromJSON(format('["self-hosted","linux","arm64","{0}"]', github.event.inputs.host)) }} needs: [build] timeout-minutes: 295 - if: github.event.inputs.mode != 'mtcollins1_boot' && github.event.inputs.mode != 'gcp_iam_converge' + if: github.event.inputs.mode != 'mtcollins1_boot' && github.event.inputs.mode != 'gcp_iam_converge' && github.event.inputs.mode != 'namecheap_observe' permissions: contents: read actions: read @@ -189,7 +189,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1534,7 +1534,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1583,7 +1583,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1653,7 +1653,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1688,7 +1688,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1727,7 +1727,7 @@ jobs: uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 with: fetch-depth: 0 - ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge') && github.sha || github.event.inputs.expected_revision || github.ref }} + ref: ${{ (github.event.inputs.mode == 'mtcollins1_boot' || github.event.inputs.mode == 'gcp_iam_converge' || github.event.inputs.mode == 'namecheap_observe') && github.sha || github.event.inputs.expected_revision || github.ref }} - name: Download release-bins artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 with: @@ -1917,3 +1917,63 @@ jobs: IAM_CONVERGE_ACCESS_TOKEN: ${{ steps.wif_auth_iam_converge.outputs.access_token }} if: github.event.inputs.mode == 'gcp_iam_converge' && steps.gcp_iam_request.outputs.approved == 'true' timeout-minutes: 15 + namecheap-observe: + runs-on: ${{ fromJSON(format('["self-hosted","linux","arm64","{0}"]', github.event.inputs.host)) }} + needs: [build] + environment: namecheap-dns + timeout-minutes: 25 + if: github.event.inputs.mode == 'namecheap_observe' + permissions: + contents: read + actions: read + id-token: write + steps: + - name: Checkout (the event sha only; no dispatch input selects these bytes) + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 + with: + fetch-depth: 0 + ref: ${{ github.sha }} + - name: Download release-bins artifact + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 + with: + name: release-bins + timeout-minutes: 10 + - name: Unpack + verify release bins (claim_executor --verify-build-artifacts; fail-closed) + id: release_bins + run: | + # 🟡 dissolve-on: ci_release_bins_unpack_verify_script — concat-built foreign-executor (GitHub Actions run:) release-bins unpack + claim_executor --verify-build-artifacts runner; membership of verified paths is derived from gunbc.ci_release_bins, but the unpack/verify transport itself remains hand-shell; DISSOLVES WHEN bash-emit (#5828 / ROADMAP 6-shell-slice0 / shell→intent Phase 2) realizes the unpack/verify runner through orchestration emit or typed host_effect_apply without a medium-as-string concat scaffold + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + mkdir -p "$ROOT/target/release" + tar -xzf "$ROOT/release-bins.tgz" -C "$ROOT/target/release" + rm -f "$ROOT/release-bins.tgz" + "$ROOT/target/release/claim_executor" --verify-build-artifacts "$ROOT/target/release/claim_executor" "$ROOT/target/release/gunbc" "$ROOT/target/release/discover_source_root_ingest" "$ROOT/target/release/claim_batch" "$ROOT/target/release/interp_recorded_fixture_witness" "$ROOT/target/release/v1_src_dag_parse" "$ROOT/target/release/auth_declared_but_unwired_witness" "$ROOT/target/release/bootstrap_witness" "$ROOT/target/release/dag_collect_fingerprint_witness" "$ROOT/target/release/diagnostics_witness" "$ROOT/target/release/effects_rest_transport_witness" "$ROOT/target/release/infer_semantics_witness" "$ROOT/target/release/parse_witness" "$ROOT/target/release/cssl_assemble" "$ROOT/target/release/namespace_structural_root_exposure_generated_witness" "$ROOT/target/release/codex_app_server_stdio_session" + timeout-minutes: 5 + - name: WIF auth (dedicated Namecheap reader) + id: wif_auth + uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 + with: + workload_identity_provider: projects/582015116396/locations/global/workloadIdentityPools/github-namecheap-dns/providers/github-namecheap-dns-oidc + service_account: namecheap-dns@gunbai-secrets.iam.gserviceaccount.com + token_format: access_token + create_credentials_file: false + timeout-minutes: 5 + - name: Observe Namecheap getHosts with the Secret Manager credential + id: namecheap_observe + run: |- + ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) + "$ROOT/target/release/gunbc" run --source-root "$ROOT/dag" --source-root "$ROOT/src/v2" --entry dag/gunbc/namecheap/observe.dag --function namecheap_observe_ci_wet + cat "$ROOT/target/namecheap-observation.json" + env: + WIF_ACCESS_TOKEN: ${{ steps.wif_auth.outputs.access_token }} + timeout-minutes: 5 + - name: Upload Namecheap read-only DNS observation + id: namecheap_secret_receipt + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: namecheap-observation + path: target/namecheap-observation.json + if-no-files-found: error + retention-days: 90 + compression-level: 0 + if: success() + timeout-minutes: 10 diff --git a/dag/extdeps/acme/dns01.dag b/dag/extdeps/acme/dns01.dag new file mode 100644 index 00000000000..148c7431acc --- /dev/null +++ b/dag/extdeps/acme/dns01.dag @@ -0,0 +1,18 @@ +module extdeps.acme.dns01 + +import std.types { String, Bool } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { + uri: Uri { scheme: Https, locator: "www.rfc-editor.org/rfc/rfc8555#section-8.4" } +} + +// The TXT value is the unpadded base64url encoding of a SHA-256 digest (32 +// octets): 43 characters; the last sextet has two zero padding bits. This is +// not the ACME challenge token or key authorization, and validates no order. +fn acme_dns01_txt_value_valid(s: String) -> Bool { + string_length(s: s) == 43 + && all(s.chars(), c => (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || (c >= 48 && c <= 57) || c == 45 || c == 95) + && string_contains(s: "AEIMQUYcgkosw048", pattern: char_at(s: s, pos: 42)) +} diff --git a/dag/extdeps/http/client.dag b/dag/extdeps/http/client.dag index 43596819376..ef3657b7174 100644 --- a/dag/extdeps/http/client.dag +++ b/dag/extdeps/http/client.dag @@ -168,6 +168,18 @@ service http.Client { } } + operation GetQueryStdinWithin { + input { url: NonEmptyStr, query: String, connect_seconds: NonEmptyStr, max_seconds: NonEmptyStr } + output { success: Bool from "exit_success", body: String from "stdout" } + readonly + transport shell { + argv: ["curl", "--disable", "-sS", "--connect-timeout", "{connect_seconds}", "--max-time", "{max_seconds}", "--get", "--data-binary", "@-", "-w", "\n%\{http_code\}", "{url}"] + stdin: query + } + exit { 0 => Unit nonzero => String "bounded GET with stdin query did not complete" } + mock_response { 0 => { success: false, body: "" } "hermetic: no live HTTP endpoint" } + } + operation PostStdinWithin { input { url: NonEmptyStr, request_body: String, connect_seconds: NonEmptyStr, max_seconds: NonEmptyStr } output { diff --git a/dag/extdeps/languages/xml/read.dag b/dag/extdeps/languages/xml/read.dag new file mode 100644 index 00000000000..fef4be48f45 --- /dev/null +++ b/dag/extdeps/languages/xml/read.dag @@ -0,0 +1,153 @@ +module extdeps.languages.xml.read + +import std.types { String, Int, Bool, List } +import std.algebra { trim } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https, locator: "www.w3.org/TR/xml/" } } + +// A bounded XML subset reader, not a general XML processor. It rejects DTDs, +// external entities, numeric references, comments, CDATA and processing +// instructions other than the initial XML declaration. Unsupported input refuses; +// it is never stripped into an apparently successful provider response. +type XmlAttribute { name: String, value: String } +type XmlElement { name: String, attributes: List, children: List, text: String } +type XmlRead = XmlParsed { element: XmlElement, next: Int } | XmlRefused + +type XmlTextRead = XmlTextParsed { value: String, next: Int } | XmlTextRefused +type XmlStartRead = XmlStartParsed { attributes: List, next: Int, closed: Bool } | XmlStartRefused + +fn xml_at(s: String, i: Int) -> String { + if i < 0 || i >= string_length(s: s) { "" } else { substring(s: s, start: i, end: i + 1) } +} +fn xml_has(s: String, i: Int, prefix: String) -> Bool { + i >= 0 && i + string_length(s: prefix) <= string_length(s: s) && substring(s: s, start: i, end: i + string_length(s: prefix)) == prefix +} +fn xml_valid_character(c: String) -> Bool { + all(c |> chars, cp => cp == 9 || cp == 10 || cp == 13 || (cp >= 32 && cp <= 55295) || (cp >= 57344 && cp <= 65533) || (cp >= 65536 && cp <= 1114111)) +} +fn xml_space(c: String) -> Bool { c == " " || c == "\t" || c == "\r" || c == "\n" } +fn xml_skip(s: String, i: Int) -> Int { + if i < string_length(s: s) && xml_space(c: xml_at(s: s, i: i)) { xml_skip(s: s, i: i + 1) } else { i } +} +fn xml_name_first(c: String) -> Bool { + c != "" && string_contains(s: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_", pattern: c) +} +fn xml_name_char(c: String) -> Bool { + xml_name_first(c: c) || (c != "" && string_contains(s: "0123456789-.:", pattern: c)) +} +fn xml_name_end(s: String, i: Int) -> Int { + if xml_name_char(c: xml_at(s: s, i: i)) { xml_name_end(s: s, i: i + 1) } else { i } +} +fn xml_entity(s: String, i: Int) -> XmlTextRead { + if xml_has(s: s, i: i, prefix: "&") { XmlTextParsed { value: "&", next: i + 5 } } + else if xml_has(s: s, i: i, prefix: "<") { XmlTextParsed { value: "<", next: i + 4 } } + else if xml_has(s: s, i: i, prefix: ">") { XmlTextParsed { value: ">", next: i + 4 } } + else if xml_has(s: s, i: i, prefix: """) { XmlTextParsed { value: "\"", next: i + 6 } } + else if xml_has(s: s, i: i, prefix: "'") { XmlTextParsed { value: "'", next: i + 6 } } + else { XmlTextRefused } +} +fn xml_text_until(s: String, i: Int, stop: String, acc: String, remaining: Int) -> XmlTextRead { + if remaining <= 0 || i >= string_length(s: s) { XmlTextRefused } + else { + let c = xml_at(s: s, i: i) + if c == stop { XmlTextParsed { value: acc, next: i } } + else if c == "<" || !xml_valid_character(c: c) || (stop != "<" && (c == "\n" || c == "\r" || c == "\t")) || xml_has(s: s, i: i, prefix: "]]>") { XmlTextRefused } + else if c == "&" { + match xml_entity(s: s, i: i) { + XmlTextRefused => XmlTextRefused + XmlTextParsed { value, next } => xml_text_until(s: s, i: next, stop: stop, acc: concat(acc, value), remaining: remaining - 1) + } + } else { xml_text_until(s: s, i: i + 1, stop: stop, acc: concat(acc, c), remaining: remaining - 1) } + } +} +fn xml_attributes(s: String, i: Int, attrs: List, remaining: Int) -> XmlStartRead { + let p = xml_skip(s: s, i: i) + if remaining <= 0 { XmlStartRefused } + else if xml_has(s: s, i: p, prefix: "/>") { XmlStartParsed { attributes: attrs, next: p + 2, closed: true } } + else if xml_at(s: s, i: p) == ">" { XmlStartParsed { attributes: attrs, next: p + 1, closed: false } } + else if p == i || !xml_name_first(c: xml_at(s: s, i: p)) { XmlStartRefused } + else { + let end = xml_name_end(s: s, i: p) + let name = substring(s: s, start: p, end: end) + let eq = xml_skip(s: s, i: end) + let q = xml_skip(s: s, i: eq + 1) + let quote = xml_at(s: s, i: q) + if attrs.any(a => a.name == name) || xml_at(s: s, i: eq) != "=" || (quote != "\"" && quote != "'") { XmlStartRefused } + else { + match xml_text_until(s: s, i: q + 1, stop: quote, acc: "", remaining: 16384) { + XmlTextRefused => XmlStartRefused + XmlTextParsed { value, next } => xml_attributes(s: s, i: next + 1, attrs: concat(attrs, [XmlAttribute { name: name, value: value }]), remaining: remaining - 1) + } + } + } +} +fn xml_body(s: String, i: Int, name: String, attrs: List, children: List, text: String, depth: Int, remaining: Int) -> XmlRead { + if remaining <= 0 || i >= string_length(s: s) { XmlRefused } + else if xml_has(s: s, i: i, prefix: "" { XmlRefused } + else { XmlParsed { element: XmlElement { name: name, attributes: attrs, children: children, text: text }, next: close + 1 } } + } else if xml_at(s: s, i: i) == "<" { + match xml_element(s: s, i: i, depth: depth - 1) { + XmlRefused => XmlRefused + XmlParsed { element, next } => xml_body(s: s, i: next, name: name, attrs: attrs, children: concat(children, [element]), text: text, depth: depth, remaining: remaining - 1) + } + } else { + match xml_text_until(s: s, i: i, stop: "<", acc: "", remaining: 16384) { + XmlTextRefused => XmlRefused + XmlTextParsed { value, next } => xml_body(s: s, i: next, name: name, attrs: attrs, children: children, text: concat(text, value), depth: depth, remaining: remaining - 1) + } + } +} +fn xml_element(s: String, i: Int, depth: Int) -> XmlRead { + if depth <= 0 || xml_at(s: s, i: i) != "<" || !xml_name_first(c: xml_at(s: s, i: i + 1)) { XmlRefused } + else { + let end = xml_name_end(s: s, i: i + 1) + let name = substring(s: s, start: i + 1, end: end) + match xml_attributes(s: s, i: end, attrs: [], remaining: 64) { + XmlStartRefused => XmlRefused + XmlStartParsed { attributes, next, closed } => + if closed { XmlParsed { element: XmlElement { name: name, attributes: attributes, children: [], text: "" }, next: next } } + else { xml_body(s: s, i: next, name: name, attrs: attributes, children: [], text: "", depth: depth, remaining: 4096) } + } + } +} +fn xml_prolog_end(s: String, i: Int, remaining: Int) -> Int { + if remaining <= 0 || i >= string_length(s: s) { -1 } + else if xml_has(s: s, i: i, prefix: "?>") { i + 2 } + else if xml_at(s: s, i: i) == "<" { -1 } + else { xml_prolog_end(s: s, i: i + 1, remaining: remaining - 1) } +} +fn xml_declaration_valid(s: String, start: Int, end: Int) -> Bool { + if end < start + 8 { false } + else { + let declaration = join([""], "") + match xml_element(s: declaration, i: 0, depth: 1) { + XmlRefused => false + XmlParsed { element: e, next } => next == string_length(s: declaration) && xml_attribute(e: e, name: "version") == "1.0" + && all(e.attributes, a => a.name == "version" || (a.name == "encoding" && (a.value == "UTF-8" || a.value == "utf-8")) || (a.name == "standalone" && (a.value == "yes" || a.value == "no"))) + } + } +} +fn read_xml_subset(s: String) -> XmlRead { + if string_length(s: s) > 262144 { XmlRefused } + else { + let first = xml_skip(s: s, i: 0) + let start = if xml_has(s: s, i: first, prefix: " XmlRefused + XmlParsed { element, next } => if xml_skip(s: s, i: next) == string_length(s: s) { XmlParsed { element: element, next: next } } else { XmlRefused } + } + } + } +} +fn xml_attribute(e: XmlElement, name: String) -> String { + match e.attributes.filter(a => a.name == name).first() { Present { value: a } => a.value Absent => "" } +} +fn xml_children(e: XmlElement, name: String) -> List { e.children.filter(c => c.name == name) } +fn xml_leaf(e: XmlElement) -> Bool { count(e.children) == 0 } diff --git a/dag/extdeps/namecheap/client.dag b/dag/extdeps/namecheap/client.dag new file mode 100644 index 00000000000..3f7d8a8375d --- /dev/null +++ b/dag/extdeps/namecheap/client.dag @@ -0,0 +1,72 @@ +module extdeps.namecheap.client + +import std.types { String, NonEmptyStr, Secret, Bool } +import extdeps.uri { uri_percent_encode_component, UriPercentComponentEncoded, UriPercentComponentRefused } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +import extdeps.http.client +import extdeps.namecheap.read_hosts { NamecheapHostsRead, NamecheapHostsObserved, NamecheapHostsRefused, namecheap_read_hosts } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https, locator: "www.namecheap.com/support/api/methods/domains-dns/get-hosts/" } } + +// GET query travels on stdin, never in argv. This interface exposes only getHosts; +// there is no caller-supplied command and no mutation operation. +fn namecheap_get_hosts(account: NonEmptyStr, credential: Secret, client_ipv4: NonEmptyStr, sld: NonEmptyStr, tld: NonEmptyStr) -> NamecheapHostsRead { + match uri_percent_encode_component(value: credential as NonEmptyStr) { + UriPercentComponentRefused { cause: _ } => NamecheapHostsRefused { reason: "Namecheap credential cannot be encoded" } + UriPercentComponentEncoded(key_wire) => + match uri_percent_encode_component(value: account) { + UriPercentComponentRefused { cause: _ } => NamecheapHostsRefused { reason: "Namecheap account cannot be encoded" } + UriPercentComponentEncoded(account_wire) => + match uri_percent_encode_component(value: client_ipv4) { + UriPercentComponentRefused { cause: _ } => NamecheapHostsRefused { reason: "Namecheap client address cannot be encoded" } + UriPercentComponentEncoded(ip_wire) => + match uri_percent_encode_component(value: sld) { + UriPercentComponentRefused { cause: _ } => NamecheapHostsRefused { reason: "Namecheap domain cannot be encoded" } + UriPercentComponentEncoded(sld_wire) => + match uri_percent_encode_component(value: tld) { + UriPercentComponentRefused { cause: _ } => NamecheapHostsRefused { reason: "Namecheap domain suffix cannot be encoded" } + UriPercentComponentEncoded(tld_wire) => { + let query = join(["ApiUser=", account_wire as String, "&UserName=", account_wire as String, "&ApiKey=", key_wire as String, + "&ClientIp=", ip_wire as String, "&SLD=", sld_wire as String, "&TLD=", tld_wire as String, + "&Command=namecheap.domains.dns.getHosts"], "") + let got = http.Client.GetQueryStdinWithin(url: "https://api.namecheap.com/xml.response" as NonEmptyStr, query: query, + connect_seconds: "5" as NonEmptyStr, max_seconds: "30" as NonEmptyStr) + let n = string_length(s: got.body) + if !got.success || n < 4 || substring(s: got.body, start: n - 4, end: n) != "\n200" { + NamecheapHostsRefused { reason: "Namecheap getHosts transport did not return HTTP 200" } + } else { + namecheap_read_hosts_for_publication(body: substring(s: got.body, start: 0, end: n - 4), + domain: join([sld as String, ".", tld as String], ""), credential: credential, key_wire: key_wire as String) + } + } + } + } + } + } + } +} + +fn namecheap_contains_credential(value: String, credential: Secret, key_wire: String) -> Bool { + string_contains(s: value, pattern: credential as String) || string_contains(s: value, pattern: key_wire) +} + +// Raw exclusion and decoded exclusion protect different representations. Refuse +// the whole observation; never redact a field and call the snapshot complete. +fn namecheap_read_hosts_for_publication(body: String, domain: String, credential: Secret, key_wire: String) -> NamecheapHostsRead { + if (credential as String) == "" || key_wire == "" || namecheap_contains_credential(value: body, credential: credential, key_wire: key_wire) { + NamecheapHostsRefused { reason: "Namecheap response contains credential material; refusing to publish it" } + } else { + match namecheap_read_hosts(body: body, domain: domain) { + NamecheapHostsRefused { reason } => NamecheapHostsRefused { reason: reason } + NamecheapHostsObserved { domain: observed_domain, hosts, result_fields } => + if namecheap_contains_credential(value: observed_domain, credential: credential, key_wire: key_wire) + || result_fields.any(f => namecheap_contains_credential(value: f.name, credential: credential, key_wire: key_wire) || namecheap_contains_credential(value: f.value, credential: credential, key_wire: key_wire)) + || hosts.any(h => h.fields.any(f => namecheap_contains_credential(value: f.name, credential: credential, key_wire: key_wire) || namecheap_contains_credential(value: f.value, credential: credential, key_wire: key_wire))) { + NamecheapHostsRefused { reason: "Namecheap decoded response contains credential material; refusing to publish it" } + } else { + NamecheapHostsObserved { domain: observed_domain, hosts: hosts, result_fields: result_fields } + } + } + } +} diff --git a/dag/extdeps/namecheap/read_hosts.dag b/dag/extdeps/namecheap/read_hosts.dag new file mode 100644 index 00000000000..0e2dbc6677d --- /dev/null +++ b/dag/extdeps/namecheap/read_hosts.dag @@ -0,0 +1,69 @@ +module extdeps.namecheap.read_hosts + +import std.types { String, List, Bool } +import std.algebra { trim } +import std.decimal { decimal_digits_only } +import extdeps.languages.xml.read { XmlElement, XmlAttribute, XmlParsed, XmlRefused, read_xml_subset, xml_attribute, xml_children, xml_leaf } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } + +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https, locator: "www.namecheap.com/support/api/methods/domains-dns/get-hosts/" } } + +type NamecheapObservedHost { fields: List } +type NamecheapHostsRead = NamecheapHostsObserved { domain: String, hosts: List, result_fields: List } | NamecheapHostsRefused { reason: String } + +fn nc_namespace_inherited(e: XmlElement) -> Bool { + !e.attributes.any(a => a.name == "xmlns" || string_contains(s: a.name, pattern: ":")) + && all(e.children, c => nc_namespace_inherited(e: c)) +} +fn nc_host_valid(e: XmlElement) -> Bool { + e.name == "Host" && xml_leaf(e: e) && trim(s: e.text) == "" + && xml_attribute(e: e, name: "Name") != "" + && xml_attribute(e: e, name: "Type") != "" + && xml_attribute(e: e, name: "Address") != "" + && xml_attribute(e: e, name: "HostId") != "" + && decimal_digits_only(s: xml_attribute(e: e, name: "TTL")) && xml_attribute(e: e, name: "TTL") != "" +} +fn nc_result(e: XmlElement, domain: String) -> NamecheapHostsRead { + if xml_attribute(e: e, name: "Domain") != domain || xml_attribute(e: e, name: "IsUsingOurDNS") != "true" || trim(s: e.text) != "" { + NamecheapHostsRefused { reason: "Namecheap result does not confirm the requested domain on its DNS" } + } else if !all(e.children, h => nc_host_valid(e: h) && count(e.children.filter(other => xml_attribute(e: other, name: "HostId") == xml_attribute(e: h, name: "HostId"))) == 1) { + NamecheapHostsRefused { reason: "Namecheap host records are incomplete or contain unsupported result elements" } + } else { + NamecheapHostsObserved { domain: domain, hosts: map(e.children, h => NamecheapObservedHost { fields: h.attributes }), result_fields: e.attributes } + } +} +fn nc_command(e: XmlElement, domain: String) -> NamecheapHostsRead { + if xml_attribute(e: e, name: "Type") != "namecheap.domains.dns.getHosts" || count(e.children) != 1 || trim(s: e.text) != "" { + NamecheapHostsRefused { reason: "Namecheap command response is ambiguous or names another command" } + } else { + match e.children.first() { + Present { value: result } => if result.name == "DomainDNSGetHostsResult" { nc_result(e: result, domain: domain) } else { NamecheapHostsRefused { reason: "Namecheap result is not getHosts" } } + Absent => NamecheapHostsRefused { reason: "Namecheap result is missing" } + } + } +} +fn namecheap_read_hosts(body: String, domain: String) -> NamecheapHostsRead { + match read_xml_subset(s: body) { + XmlRefused => NamecheapHostsRefused { reason: "Namecheap returned malformed or unsupported XML" } + XmlParsed { element: root, next: _ } => + let errors = xml_children(e: root, name: "Errors") + let commands = xml_children(e: root, name: "CommandResponse") + let requested = xml_children(e: root, name: "RequestedCommand") + if root.name != "ApiResponse" || xml_attribute(e: root, name: "Status") != "OK" + || xml_attribute(e: root, name: "xmlns") != "http://api.namecheap.com/xml.response" + || trim(s: root.text) != "" + || count(errors) != 1 || !all(errors, e => xml_leaf(e: e) && trim(s: e.text) == "") + || !all(xml_children(e: root, name: "Warnings"), e => xml_leaf(e: e) && trim(s: e.text) == "") + || count(requested) != 1 || !all(requested, e => xml_leaf(e: e) && trim(s: e.text) == "namecheap.domains.dns.getHosts") + || count(commands) != 1 || !all(root.children, c => nc_namespace_inherited(e: c)) + || !all(root.children, c => c.name == "Errors" || c.name == "Warnings" || c.name == "RequestedCommand" || c.name == "CommandResponse" || c.name == "Server" || c.name == "GMTTimeDifference" || c.name == "ExecutionTime") { + NamecheapHostsRefused { reason: "Namecheap did not return an unambiguous successful getHosts envelope" } + } else { + match commands.first() { + Present { value: command } => nc_command(e: command, domain: domain) + Absent => NamecheapHostsRefused { reason: "Namecheap command response is missing" } + } + } + } +} diff --git a/dag/extdeps/network/ipify.dag b/dag/extdeps/network/ipify.dag new file mode 100644 index 00000000000..991d9739e06 --- /dev/null +++ b/dag/extdeps/network/ipify.dag @@ -0,0 +1,6 @@ +module extdeps.network.ipify +import std.types { NonEmptyStr } +import extdeps.external_authority { ExternalAuthority } +import extdeps.uri { Uri, Https } +data extdeps_external_authority_anchor: ExternalAuthority = ExternalAuthority { uri: Uri { scheme: Https, locator: "www.ipify.org/" } } +data ipv4_echo_url: NonEmptyStr = "https://api.ipify.org" diff --git a/dag/gunbc/auth/gcp_iam_converge.dag b/dag/gunbc/auth/gcp_iam_converge.dag index 177709a40ab..9c25c167661 100644 --- a/dag/gunbc/auth/gcp_iam_converge.dag +++ b/dag/gunbc/auth/gcp_iam_converge.dag @@ -25,6 +25,7 @@ import gunbc.auth.heal_publisher_provision { heal_publisher_dedicated_federation, impersonation_cell, observe_and_classify, rest_ok, rest_refusal_text, outcome_is_not_found, } +import gunbc.namecheap.federation_provision { namecheap_dedicated_federation } import gunbc.auth.mtcollins1_boot_federation_provision { mtcollins1_boot_dedicated_federation } import gunbc.auth.mtcollins1_boot_federation { pinned_version_accessor_grant_for } import gunbc.auth.approval_request_submission { approval_submission_mac_key_secret_ref } @@ -207,6 +208,7 @@ fn gcp_iam_observe_dedicated_federation() -> DedicatedFederation { data gcp_iam_converge_targets: List = [ heal_publisher_dedicated_federation(), mtcollins1_boot_dedicated_federation(), + namecheap_dedicated_federation(), ] fn service_account_resource(email: ServiceAccountEmail) -> String { diff --git a/dag/gunbc/ci/ci_spec.dag b/dag/gunbc/ci/ci_spec.dag index 67795851182..46d77a8137d 100644 --- a/dag/gunbc/ci/ci_spec.dag +++ b/dag/gunbc/ci/ci_spec.dag @@ -1,4 +1,6 @@ module gunbc.ci_spec + +import gunbc.namecheap.observation_artifact { namecheap_observation_receipt_path } import gunbc.runner_microvm_slot_unit { microvm_slot_start_receipt_path } import gunbc.fleet.app_control_plane_inspection { app_control_plane_receipt_path } import gunbc.cloudflare.r2_permission_group_observe { r2_mint_preflight_receipt_path } @@ -889,6 +891,11 @@ data gunbc_ci_runner_password_session_tool_converge_target: GunbcRunStepTarget = function: "runner_password_session_tool_converge_ci_wet", } +data gunbc_ci_namecheap_observe_target: GunbcRunStepTarget = GunbcRunStepTarget { + entry: "dag/gunbc/namecheap/observe.dag", + function: "namecheap_observe_ci_wet", +} + data gunbc_ci_host_credential_custody_converge_target: GunbcRunStepTarget = GunbcRunStepTarget { entry: "dag/gunbc/fleet/host_credential_custody_converge.dag", function: "host_credential_custody_converge_ci_wet", @@ -1252,6 +1259,7 @@ fn gunbc_run_step_targets() -> List { gunbc_ci_site_pxe_edge_converge_target, gunbc_ci_runner_password_session_tool_converge_target, gunbc_ci_host_credential_custody_converge_target, + gunbc_ci_namecheap_observe_target, gunbc_ci_runner_microvm_boot_probe_target, gunbc_ci_microvm_slot_start_target, gunbc_ci_host_reset_return_target, @@ -3327,3 +3335,14 @@ fn gunbc_ci_heal_publication_publish_invoke() -> String { receipt_rel: none ) } + + +fn gunbc_ci_namecheap_observe_invoke() -> String { + gunbc_run_step_script( + source_roots: witness_layer_roots, + entry: gunbc_ci_namecheap_observe_target.entry, + function: gunbc_ci_namecheap_observe_target.function, + claim_run: false, + receipt_rel: namecheap_observation_receipt_path + ) +} diff --git a/dag/gunbc/fleet/fleet_converge_workflow.dag b/dag/gunbc/fleet/fleet_converge_workflow.dag index ca2e5cf7975..c28dae01e8c 100644 --- a/dag/gunbc/fleet/fleet_converge_workflow.dag +++ b/dag/gunbc/fleet/fleet_converge_workflow.dag @@ -1,5 +1,10 @@ module gunbc.fleet_converge_workflow +import gunbc.namecheap.observation_artifact { namecheap_observation_receipt_path } + +import gunbc.namecheap.federation { namecheap_environment, namecheap_provider_resource, namecheap_service_account } +import gunbc.ci_spec { gunbc_ci_namecheap_observe_invoke } + import gunbc.runner_microvm_slot_unit { microvm_slot_unit_timeout_stop, microvm_slot_start_receipt_path } import std.measure { minute_count, second_count } @@ -278,6 +283,7 @@ type FleetConvergeWorkflowMode | MicrovmRunnerGroupEnsure | OrgRunnerRosterObserve | GcpIamConverge + | NamecheapObserve fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String { match mode { @@ -325,6 +331,7 @@ fn fleet_converge_workflow_mode_wire(mode: FleetConvergeWorkflowMode) -> String ApprovalDeviceEnrolmentCodeIssue => "approval_device_enrolment_code_issue" MtCollins1Boot => "mtcollins1_boot" GcpIamConverge => "gcp_iam_converge" + NamecheapObserve => "namecheap_observe" PairServingD0 => "pair_serving_d0" MtCollins1FanObserve => "mtcollins1_fan_observe" FabricWriterIdentityObserve => "fabric_writer_identity_observe" @@ -355,7 +362,7 @@ fn fleet_converge_spark_target_description() -> String { ], "") } -data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, MicrovmNetworkApply, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, SparkV41CheckpointMaterialize, SparkV41RowStoreEncode, SparkV41RowStoreReadback, SparkV41EngramDifferential, SparkV41RuntimeImageBuild, SparkV41RuntimeImageDistribute, DashboardDeploy, ApprovalBrokerDarkInstall, MicrovmControllerInstall, MicrovmSlotStart, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, ApprovalDeviceEnrolmentCodeIssue, MtCollins1Boot, MtCollins1FanObserve, MtCollins1CensusImagePublish, HostCredentialCustodyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve, PairServingD0, MicrovmRunnerGroupEnsure, GcpIamConverge] +data fleet_converge_workflow_modes: List = [FullHostPlan, LaunchEnvironmentPlan, AllocationStorePlan, FleetApply, OrgActionsObserve, AppControlPlaneObserve, MicrovmHostConverge, MicrovmNetworkObserve, MicrovmNetworkApply, GuestImageObserve, GuestImageConverge, MicrovmBootProbe, SparkGrants, SparkBootstrap, SparkServingApply, SparkNativeServingApply, SparkRuntimeImageProbe, SparkV41CheckpointMaterialize, SparkV41RowStoreEncode, SparkV41RowStoreReadback, SparkV41EngramDifferential, SparkV41RuntimeImageBuild, SparkV41RuntimeImageDistribute, DashboardDeploy, ApprovalBrokerDarkInstall, MicrovmControllerInstall, MicrovmSlotStart, RlmLaunchDeploymentReceipt, HostResetReturn, RunnerHostFileObserve, RunnerHostFileConverge, SitePxeEdgeObserve, SitePxeEdgeConverge, RunnerPasswordSessionToolConverge, R2MintPreflight, R2ObjectWriteMint, OrgRunnerRosterObserve, ApprovalKeyringConverge, ApprovalDeviceEnrolmentCodeIssue, MtCollins1Boot, MtCollins1FanObserve, MtCollins1CensusImagePublish, HostCredentialCustodyConverge, AppKeyVersionVerify, R2BucketEnsure, R2BucketAdminMint, FabricWriterIdentityObserve, PairServingD0, MicrovmRunnerGroupEnsure, GcpIamConverge, NamecheapObserve] // WHICH SCOPE A MODE SELECTS, WHERE IT SELECTS ONE AT ALL. // // EVERY MODE IS NAMED, AND THE WILDCARD IS DELIBERATELY ABSENT. A `_ => none` would read the same @@ -422,6 +429,7 @@ fn fleet_converge_mode_scope(mode: FleetConvergeWorkflowMode) -> FleetConvergeSc ApprovalDeviceEnrolmentCodeIssue => none MtCollins1Boot => none GcpIamConverge => none + NamecheapObserve => none PairServingD0 => none MtCollins1FanObserve => none FabricWriterIdentityObserve => none @@ -552,6 +560,7 @@ fn fleet_converge_mode_fleet_ssh_key_demand(mode: FleetConvergeWorkflowMode) -> MtCollins1CensusImagePublish => FleetSshKeyNotConsumed MicrovmRunnerGroupEnsure => FleetSshKeyNotConsumed GcpIamConverge => FleetSshKeyNotConsumed + NamecheapObserve => FleetSshKeyNotConsumed } } @@ -1110,6 +1119,7 @@ fn fleet_converge_mode_mutation_domain(mode: FleetConvergeWorkflowMode) -> Fleet MicrovmRunnerGroupEnsure => ExecutorDomain OrgRunnerRosterObserve => ExecutorDomain GcpIamConverge => ExecutorDomain + NamecheapObserve => ExecutorDomain } } @@ -3023,6 +3033,7 @@ fn fleet_converge_spark_v41_row_store_readback_step() -> Step { data fleet_converge_checkout_ref_expr: String = join([ "${{ (github.event.inputs.mode == '", fleet_converge_workflow_mode_wire(mode: MtCollins1Boot), "' || github.event.inputs.mode == '", fleet_converge_workflow_mode_wire(mode: GcpIamConverge), + "' || github.event.inputs.mode == '", fleet_converge_workflow_mode_wire(mode: NamecheapObserve), "') && github.sha || github.event.inputs.expected_revision || github.ref }}", ], "") @@ -3080,6 +3091,7 @@ fn fleet_converge_consumer_prelude_steps() -> List { data fleet_converge_shared_job_if: String = join([ "github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: MtCollins1Boot), "'", " && github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: GcpIamConverge), "'", + " && github.event.inputs.mode != '", fleet_converge_workflow_mode_wire(mode: NamecheapObserve), "'", ], "") // THE RUNNER AND THE CONCURRENCY DOMAIN ARE FIXED TO THE STORE HOST, never read from the dispatch's @@ -3413,6 +3425,7 @@ data fleet_converge_job_edges: List = [ FleetConvergeJobEdge { id: "approval-broker-dark-install" as NonEmptyStr, needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: "microvm-controller-install" as NonEmptyStr, needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: "rlm-launch-deployment-receipt" as NonEmptyStr, needs: [fleet_converge_release_bins_job_id as String] }, + FleetConvergeJobEdge { id: "namecheap-observe" as NonEmptyStr, needs: [fleet_converge_release_bins_job_id as String] }, FleetConvergeJobEdge { id: "gcp-iam-converge" as NonEmptyStr, needs: [fleet_converge_release_bins_job_id as String] }, ] @@ -3450,6 +3463,7 @@ fn fleet_converge_jobs() -> List { fleet_converge_microvm_controller_install_job(), fleet_converge_rlm_launch_deployment_receipt_job(), fleet_converge_gcp_iam_converge_job(), + fleet_converge_namecheap_observe_job(), ] } @@ -3781,3 +3795,44 @@ fn fleet_converge_yml_after_reset_assignment() -> FleetConvergeYamlGenerationOut FleetConvergeYamlGenerationRefused { reason: fleet_converge_capability_closure_refusal } } } + + +fn fleet_converge_namecheap_observe_job() -> Job { + Job { + id: "namecheap-observe", name: none, + runner: fleet_converge_host_pinned_runner_spec(), + environment: Present { value: namecheap_environment as String }, + steps: [ + fleet_converge_event_sha_checkout_step(), + ci_release_bins_download_step(), + ci_release_bins_unpack_verify_step(), + ci_wif_auth_step_for( + name: "WIF auth (dedicated Namecheap reader)", + provider_resource: namecheap_provider_resource(), + service_account: namecheap_service_account as String, + ), + RunStep { + name: Present { value: "Observe Namecheap getHosts with the Secret Manager credential" }, + id: Present { value: "namecheap_observe" }, + run: gunbc_ci_namecheap_observe_invoke(), shell: none, + env: Present { value: [kv(key: "WIF_ACCESS_TOKEN", value: yaml_string(s: "${{ steps.wif_auth.outputs.access_token }}"))] }, + working_directory: none, if_condition: none, continue_on_error: none, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes }, + }, + fleet_converge_receipt_upload_step( + id: "namecheap_secret_receipt", name: "Upload Namecheap read-only DNS observation", + artifact: "namecheap-observation", path: namecheap_observation_receipt_path, + if_condition: "success()", + ), + ], + needs: fleet_converge_job_needs(id: "namecheap-observe" as NonEmptyStr), + env: none, outputs: none, + if_condition: Present { value: fleet_converge_mode_step_if(mode: NamecheapObserve) }, + timeout_minutes: Present { value: gunbc_ci_aux_step_timeout_minutes + 20 }, + continue_on_error: none, concurrency: none, + permissions: Present { value: WorkflowPermissions { + contents: Present { value: PermRead }, pull_requests: none, issues: none, + actions: Present { value: PermRead }, id_token: Present { value: PermWrite }, + } }, + } +} diff --git a/dag/gunbc/namecheap/account.dag b/dag/gunbc/namecheap/account.dag new file mode 100644 index 00000000000..b9a78bfe667 --- /dev/null +++ b/dag/gunbc/namecheap/account.dag @@ -0,0 +1,22 @@ +module gunbc.namecheap.account + +import std.types { String, NonEmptyStr } + +data namecheap_domain_sld: NonEmptyStr = "gunb" +data namecheap_domain_tld: NonEmptyStr = "ai" + +// Operator reports, not an authenticated Namecheap or stable-egress observation. +// The allowlist must be re-observed when the selected DNS controller changes. +type NamecheapAccountReport { + username: String + domain: String + api_allowlist_ipv4: String + allowlist_label: String + source: String +} + +data namecheap_account_report: NamecheapAccountReport = NamecheapAccountReport { + username: "briansrls", domain: join([namecheap_domain_sld as String, ".", namecheap_domain_tld as String], ""), + api_allowlist_ipv4: "96.224.201.7", allowlist_label: "dev", + source: "owner Namecheap API allowlist console report 2026-09-27", +} diff --git a/dag/gunbc/namecheap/credential.dag b/dag/gunbc/namecheap/credential.dag new file mode 100644 index 00000000000..6f99ca91251 --- /dev/null +++ b/dag/gunbc/namecheap/credential.dag @@ -0,0 +1,33 @@ +module gunbc.namecheap.credential + +import std.types { String, NonEmptyStr } +import extdeps.cloud.gcp.secret_ref { SecretRef, HashPending } +import extdeps.cloud.gcp.secret_manager { sm_version_id_is_number } +import gunbc.fleet_secrets_config { fleet_secrets_gcp_project } + +// Owner reported this container; neither existence nor an enabled version is +// inferred from that report. Discovery resolves the alias once and records the +// numeric version. Reviewed mutations must select an exact version instead. +data namecheap_api_key_secret_ref: SecretRef = SecretRef { + project: fleet_secrets_gcp_project, + secret: "namecheap-api-key" as NonEmptyStr, + version: "latest" as NonEmptyStr, + hash_state: HashPending, +} + +type NamecheapCredentialSelection + = NamecheapCredentialPinned { ref: SecretRef } + | NamecheapCredentialPinRequired { reason: String } + +fn namecheap_credential_select(version: String) -> NamecheapCredentialSelection { + if !sm_version_id_is_number(version: version) { + NamecheapCredentialPinRequired { reason: "Namecheap execution requires an observed numeric Secret Manager version; an alias or missing version cannot bind a reviewed DNS operation" } + } else { + NamecheapCredentialPinned { ref: SecretRef { + project: namecheap_api_key_secret_ref.project, + secret: namecheap_api_key_secret_ref.secret, + version: version as NonEmptyStr, + hash_state: HashPending, + } } + } +} diff --git a/dag/gunbc/namecheap/credential_read.dag b/dag/gunbc/namecheap/credential_read.dag new file mode 100644 index 00000000000..b525837309d --- /dev/null +++ b/dag/gunbc/namecheap/credential_read.dag @@ -0,0 +1,17 @@ +module gunbc.namecheap.credential_read + +import std.types { String, NonEmptyStr } +import gunbc.namecheap.credential { namecheap_credential_select, NamecheapCredentialPinned, NamecheapCredentialPinRequired } +import gunbc.auth.access_token_source { WorkloadIdentityToken } +import gunbc.auth.secret_ref_credential { SecretCredentialFetch, SecretCredentialFetchRefused, fetch_secret_ref_credential } + +// Routine custody reads use the dedicated Namecheap job's scoped WIF access. +// The shared approval-gated IAM estate plan provisions that federation. This fetch neither grants IAM access nor authorizes a DNS mutation. +// A DNS apply consumer must pass its operation's approval gate before spending +// these bytes and must never render the returned Secret into a receipt or argv. +fn namecheap_credential_fetch(version: String) -> SecretCredentialFetch { + match namecheap_credential_select(version: version) { + NamecheapCredentialPinRequired { reason } => SecretCredentialFetchRefused { reason: reason as NonEmptyStr } + NamecheapCredentialPinned { ref } => fetch_secret_ref_credential(secret_ref: ref, token_source: WorkloadIdentityToken) + } +} diff --git a/dag/gunbc/namecheap/federation.dag b/dag/gunbc/namecheap/federation.dag new file mode 100644 index 00000000000..30d50790d66 --- /dev/null +++ b/dag/gunbc/namecheap/federation.dag @@ -0,0 +1,35 @@ +module gunbc.namecheap.federation + +import std.types { String, NonEmptyStr, List, Map } +import v2.std.optional { Present } +import extdeps.cloud.gcp.gcp { ServiceAccountEmail, WifProvider, wif_provider_resource, wif_pool_resource } +import gunbc.auth.github_gcp_federation { fleet_secrets_project_number, github_wif_issuer_uri, federated_service_account_member } +import gunbc.auth.oidc_claim_pins { OidcClaimPin, claim_pins_attribute_condition, fleet_converge_job_claim_pins } + +data namecheap_environment: NonEmptyStr = "namecheap-dns" +data namecheap_wif_pool_id: NonEmptyStr = "github-namecheap-dns" +data namecheap_wif_provider_id: NonEmptyStr = "github-namecheap-dns-oidc" +data namecheap_service_account: ServiceAccountEmail = "namecheap-dns@gunbai-secrets.iam.gserviceaccount.com" +data namecheap_attribute_mapping: Map = { + "google.subject": "assertion.sub", + "attribute.repository_id": "assertion.repository_id", + "attribute.workflow_ref": "assertion.workflow_ref" +} + +fn namecheap_claim_pins() -> List { + fleet_converge_job_claim_pins(environment: namecheap_environment) +} +fn namecheap_attribute_condition() -> String { + claim_pins_attribute_condition(pins: namecheap_claim_pins()) +} +fn namecheap_principal_set() -> String { + join(["principalSet://iam.googleapis.com/", wif_pool_resource(project_number: fleet_secrets_project_number, pool_id: namecheap_wif_pool_id as String), "/*"], "") +} +fn namecheap_provider_resource() -> String { + wif_provider_resource(project_number: fleet_secrets_project_number, provider: WifProvider { + name: namecheap_wif_provider_id, pool: namecheap_wif_pool_id, + issuer_uri: github_wif_issuer_uri, + attribute_mapping: Present { value: namecheap_attribute_mapping }, + attribute_condition: Present { value: namecheap_attribute_condition() }, + }) +} diff --git a/dag/gunbc/namecheap/federation_provision.dag b/dag/gunbc/namecheap/federation_provision.dag new file mode 100644 index 00000000000..0bc96d02b47 --- /dev/null +++ b/dag/gunbc/namecheap/federation_provision.dag @@ -0,0 +1,29 @@ +module gunbc.namecheap.federation_provision + +import gunbc.auth.heal_publisher_provision { DedicatedFederation } +import gunbc.auth.gcp_secret_access { secret_accessor_grant } +import gunbc.auth.github_gcp_federation { federated_service_account_member } +import gunbc.namecheap.credential { namecheap_api_key_secret_ref } +import gunbc.namecheap.federation { + namecheap_wif_pool_id, namecheap_wif_provider_id, namecheap_attribute_mapping, + namecheap_attribute_condition, namecheap_principal_set, namecheap_service_account, +} + +// Desired state consumed by the shared approval-gated IAM estate plan. This does +// not claim the federation, provider, account or binding already exists. +fn namecheap_dedicated_federation() -> DedicatedFederation { + DedicatedFederation { + label: "Namecheap DNS credential reader", + pool_id: namecheap_wif_pool_id, + pool_display_name: "Namecheap DNS", + provider_id: namecheap_wif_provider_id, + attribute_mapping: namecheap_attribute_mapping, + attribute_condition: namecheap_attribute_condition(), + principal_set: namecheap_principal_set(), + service_account: namecheap_service_account, + service_account_id: "namecheap-dns", + service_account_display_name: "Namecheap DNS credential reader", + service_account_member: federated_service_account_member(email: namecheap_service_account), + secret_grants: [secret_accessor_grant(target: namecheap_api_key_secret_ref)], + } +} diff --git a/dag/gunbc/namecheap/observation_artifact.dag b/dag/gunbc/namecheap/observation_artifact.dag new file mode 100644 index 00000000000..a0e7964616f --- /dev/null +++ b/dag/gunbc/namecheap/observation_artifact.dag @@ -0,0 +1,6 @@ +module gunbc.namecheap.observation_artifact + +import std.types { String } + +// The observer writer, console display and workflow uploader consume this path. +data namecheap_observation_receipt_path: String = "target/namecheap-observation.json" diff --git a/dag/gunbc/namecheap/observe.dag b/dag/gunbc/namecheap/observe.dag new file mode 100644 index 00000000000..a6eeb136692 --- /dev/null +++ b/dag/gunbc/namecheap/observe.dag @@ -0,0 +1,84 @@ +module gunbc.namecheap.observe + +import gunbc.namecheap.observation_artifact { namecheap_observation_receipt_path } + +import std.types { String, NonEmptyStr, List } +import std.algebra { trim } +import std.decimal { decimal_digits_only } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.http.client +import extdeps.network.ipify { ipv4_echo_url } +import extdeps.languages.json.emit { JsonValue, json_object, json_kv, json_string, json_array, serialize_json } +import extdeps.namecheap.client { namecheap_get_hosts } +import extdeps.namecheap.read_hosts { NamecheapObservedHost, NamecheapHostsObserved, NamecheapHostsRefused } +import gunbc.namecheap.credential { namecheap_api_key_secret_ref } +import gunbc.namecheap.account { namecheap_account_report, namecheap_domain_sld, namecheap_domain_tld } +import gunbc.clock_read { clock_now_probed_at } +import gunbc.actions_run_binding { actions_variable_trimmed } +import gunbc.auth.access_token_source { WorkloadIdentityToken } +import gunbc.auth.secret_ref_credential { + fetch_secret_ref_credential, SecretCredentialReady, SecretCredentialAccessUpsertRequired, + SecretCredentialWireDecodeRefused, SecretCredentialFetchRefused, SecretCredentialResolvedVersionMismatch, +} + +fn namecheap_host_json(host: NamecheapObservedHost) -> JsonValue { + json_object(members: map(host.fields, f => json_kv(key: f.name, value: json_string(s: f.value)))) +} + +// Discovery resolves latest once and records the exact returned identity. The +// execution reader requires that numeric pin. The only provider command is getHosts. +fn namecheap_observe_at(run_id: String, attempt: String, revision: String, observed_at: String) -> ProcessExit { + let echo = http.Client.GetWithin(url: ipv4_echo_url, max_seconds: "15" as NonEmptyStr) + let egress = trim(s: echo.body) + if !echo.success || egress != namecheap_account_report.api_allowlist_ipv4 { + exit_failure(reason: "DNS controller public egress does not match the reported Namecheap allowlist; update the controller admission before retrying") + } else { + match fetch_secret_ref_credential(secret_ref: namecheap_api_key_secret_ref, token_source: WorkloadIdentityToken) { + SecretCredentialReady { credential, resolved_version } => + if (credential as String) == "" { exit_failure(reason: "Namecheap credential version is empty") } + else { + match namecheap_get_hosts(account: namecheap_account_report.username as NonEmptyStr, credential: credential, + client_ipv4: egress as NonEmptyStr, sld: namecheap_domain_sld, tld: namecheap_domain_tld) { + NamecheapHostsRefused { reason } => exit_failure(reason: reason) + NamecheapHostsObserved { domain: observed_domain, hosts, result_fields } => + let receipt = serialize_json(v: json_object(members: [ + json_kv(key: "schema", value: json_string(s: "gunbc-namecheap-observation/v1")), + json_kv(key: "standing", value: json_string(s: "getHosts-observed")), + json_kv(key: "run_id", value: json_string(s: run_id)), + json_kv(key: "run_attempt", value: json_string(s: attempt)), + json_kv(key: "revision", value: json_string(s: revision)), + json_kv(key: "started_at", value: json_string(s: observed_at)), + json_kv(key: "exact_credential_version", value: json_string(s: resolved_version as String)), + json_kv(key: "account", value: json_string(s: namecheap_account_report.username)), + json_kv(key: "public_egress_ipv4", value: json_string(s: egress)), + json_kv(key: "domain", value: json_string(s: observed_domain)), + json_kv(key: "result_fields", value: json_object(members: map(result_fields, f => json_kv(key: f.name, value: json_string(s: f.value))))), + json_kv(key: "hosts", value: json_array(elements: map(hosts, h => namecheap_host_json(host: h)))), + json_kv(key: "mail_mode", value: json_string(s: "unobserved")), + json_kv(key: "write_authority", value: json_string(s: "withheld")), + ])) + let written = Filesystem.Write(path: namecheap_observation_receipt_path, content: receipt) + if written.success { ExitSuccess } else { exit_failure(reason: "Could not write the Namecheap observation receipt") } + } + } + SecretCredentialAccessUpsertRequired { plan: _ } => exit_failure(reason: "Workload identity requires provisioning; no operator token fallback") + SecretCredentialWireDecodeRefused { identity_cause: _, payload_cause: _ } => exit_failure(reason: "Secret Manager response did not decode") + SecretCredentialFetchRefused { reason } => exit_failure(reason: reason as String) + SecretCredentialResolvedVersionMismatch { requested: _, resolved: _ } => exit_failure(reason: "Secret Manager returned a different resource than requested") + } + } +} +fn namecheap_observe_ci_wet() -> ProcessExit { + let run_id = actions_variable_trimmed(name: "GITHUB_RUN_ID" as NonEmptyStr) + let attempt = actions_variable_trimmed(name: "GITHUB_RUN_ATTEMPT" as NonEmptyStr) + let revision = actions_variable_trimmed(name: "GITHUB_SHA" as NonEmptyStr) + if run_id == "" || attempt == "" || revision == "" || !decimal_digits_only(s: run_id) || !decimal_digits_only(s: attempt) { + exit_failure(reason: "Namecheap observation requires a bound workflow run, attempt and revision") + } else { + match clock_now_probed_at() { + Absent => exit_failure(reason: "Namecheap observation clock could not be read") + Present { value: now } => namecheap_observe_at(run_id: run_id, attempt: attempt, revision: revision, observed_at: now as String) + } + } +} diff --git a/dag/gunbc/namecheap/planning/intent.dag b/dag/gunbc/namecheap/planning/intent.dag new file mode 100644 index 00000000000..2c7984cf43e --- /dev/null +++ b/dag/gunbc/namecheap/planning/intent.dag @@ -0,0 +1,55 @@ +module gunbc.namecheap.planning.intent + +import std.types { String, Bool, List } +import extdeps.acme.dns01 { acme_dns01_txt_value_valid } +import gunbc.namecheap.account { namecheap_account_report } +import extdeps.languages.json.emit { JsonValue, JsonObject, JsonArray } +import extdeps.network.ipv4 { Ipv4Address, parse_ipv4_address, Ipv4Parsed } +import gunbc.namecheap.planning.observation { dns_string, dns_field, dns_elements, dns_unique_object, dns_string_object } + +type DevDnsService = TrackerDev | ApprovalsDev +fn dev_dns_label(s: DevDnsService) -> String { match s { TrackerDev => "tracker-dev" ApprovalsDev => "approvals-dev" } } +fn dev_dns_origin(s: DevDnsService) -> String { join(["https://", dev_dns_label(s: s), ".", namecheap_account_report.domain], "") } +fn dev_dns_service_id(s: DevDnsService) -> String { concat("svc:", dev_dns_label(s: s)) } +fn dev_dns_challenge_name(s: DevDnsService) -> String { concat("_acme-challenge.", dev_dns_label(s: s)) } +type DevServiceAddress { service: DevDnsService, ipv4: Ipv4Address, observation_ref: String } +type Dns01Action = PublishChallenge | RetireChallenge +type Dns01Change { service: DevDnsService, action: Dns01Action, value: String, ownership_ref: String } +type DevDnsIntent { services: List, challenges: List, source: JsonValue } +type DevDnsIntentRead = DevDnsIntentReadOk { intent: DevDnsIntent } | DevDnsIntentReadRefused { reason: String } + +fn dns_keys_exact(v: JsonValue, keys: List) -> Bool { + dns_unique_object(v: v) && match v { JsonObject { members: object_members } => count(object_members) == count(keys) && all(object_members, m => keys.any(k => k == m.key)) _ => false } +} +fn dns_known_service(s: String) -> Bool { s == dev_dns_service_id(s: TrackerDev) || s == dev_dns_service_id(s: ApprovalsDev) } +// Only called after dns_known_service admission. +fn dns_service(s: String) -> DevDnsService { if s == dev_dns_service_id(s: TrackerDev) { TrackerDev } else { ApprovalsDev } } +fn dns_tailnet_ipv4(s: String) -> Bool { + match parse_ipv4_address(text: s) { Ipv4Parsed { address: a } => a.octet1 == 100 && a.octet2 >= 64 && a.octet2 <= 127 _ => false } +} +fn dns_intent_read(v: JsonValue) -> DevDnsIntentRead { + let services = dns_field(v: v, key: "services") + let challenges = dns_field(v: v, key: "challenges") + let ss = dns_elements(v: services) + let cs = dns_elements(v: challenges) + if !dns_keys_exact(v: v, keys: ["schema", "services", "challenges"]) || dns_string(v: v, key: "schema") != "gunbc-dev-dns-intent/v1" + || !(match services { JsonArray { elements: _ } => true _ => false }) || !(match challenges { JsonArray { elements: _ } => true _ => false }) + || count(ss) + count(cs) == 0 + || !all(ss, s => dns_string_object(v: s) && dns_keys_exact(v: s, keys: ["service", "ipv4", "observation_ref"]) + && dns_known_service(s: dns_string(v: s, key: "service")) && dns_tailnet_ipv4(s: dns_string(v: s, key: "ipv4")) && dns_string(v: s, key: "observation_ref") != "" + && count(ss.filter(other => dns_string(v: other, key: "service") == dns_string(v: s, key: "service"))) == 1) + || !all(cs, c => dns_string_object(v: c) && dns_keys_exact(v: c, keys: ["service", "action", "value", "ownership_ref"]) + && dns_known_service(s: dns_string(v: c, key: "service")) && (dns_string(v: c, key: "action") == "publish" || dns_string(v: c, key: "action") == "retire") + && acme_dns01_txt_value_valid(s: dns_string(v: c, key: "value")) && dns_string(v: c, key: "ownership_ref") != "" + && count(cs.filter(other => dns_string(v: other, key: "service") == dns_string(v: c, key: "service") && dns_string(v: other, key: "value") == dns_string(v: c, key: "value"))) == 1) { + DevDnsIntentReadRefused { reason: "Intent has missing/unknown fields, an unsupported service/address, duplicate changes, or an invalid DNS-01 value/evidence reference" } + } else { + let addresses = flat_map(ss, s => match parse_ipv4_address(text: dns_string(v: s, key: "ipv4")) { + Ipv4Parsed { address: a } => [DevServiceAddress { service: dns_service(s: dns_string(v: s, key: "service")), ipv4: a, observation_ref: dns_string(v: s, key: "observation_ref") }] + _ => [] + }) + DevDnsIntentReadOk { intent: DevDnsIntent { services: addresses, challenges: map(cs, c => Dns01Change { + service: dns_service(s: dns_string(v: c, key: "service")), action: if dns_string(v: c, key: "action") == "publish" { PublishChallenge } else { RetireChallenge }, + value: dns_string(v: c, key: "value"), ownership_ref: dns_string(v: c, key: "ownership_ref") }), source: v } } + } +} diff --git a/dag/gunbc/namecheap/planning/observation.dag b/dag/gunbc/namecheap/planning/observation.dag new file mode 100644 index 00000000000..d27b05eb57d --- /dev/null +++ b/dag/gunbc/namecheap/planning/observation.dag @@ -0,0 +1,94 @@ +module gunbc.namecheap.planning.observation + +import std.types { String, Bool, List, Int } +import extdeps.languages.json.emit { JsonValue, JsonObject, JsonString, JsonArray, JsonNull } +import extdeps.languages.json.parse { json_field, FieldRead, json_field_string } +import extdeps.languages.xml.read { XmlAttribute, XmlElement } +import extdeps.namecheap.read_hosts { NamecheapObservedHost, nc_host_valid } +import gunbc.namecheap.account { namecheap_account_report } +import gunbc.namecheap.credential { namecheap_api_key_secret_ref } +import gunbc.auth.github_gcp_federation { fleet_secrets_project_number } +import extdeps.cloud.gcp.secret_manager { SmResolvedVersionIdentity } +import extdeps.cloud.gcp.secret_ref { resolve_returned_version, SecretRefVersionResolvedAlias, SecretRefVersionResolvedExact } +import gunbc.auth.approval_capability { utc_instant_is_canonical, utc_instant_seconds_after } +import std.decimal { decimal_digits_only } +import extdeps.numeric.base16 { base16_is_lower_digit_code_point } + +type DnsObservation { + receipt: JsonValue + run_id: String + attempt: String + revision: String + started_at: String + credential_version: String + result_fields: List + hosts: List +} +type DnsObservationRead = DnsObservationReadOk { observation: DnsObservation } | DnsObservationReadRefused { reason: String } + +// These projections are consumed only after the caller validates the wire shape. +fn dns_string(v: JsonValue, key: String) -> String { + match json_field_string(obj: v, key: key) { FieldRead { value: s } => s _ => "" } +} +fn dns_field(v: JsonValue, key: String) -> JsonValue { + match json_field(obj: v, key: key) { FieldRead { value: x } => x _ => JsonNull } +} +fn dns_elements(v: JsonValue) -> List { match v { JsonArray { elements } => elements _ => [] } } +fn dns_string_object(v: JsonValue) -> Bool { + match v { + JsonObject { members: object_members } => all(object_members, f => f.key != "" && count(object_members.filter(other => other.key == f.key)) == 1 && match f.value { JsonString { value: _ } => true _ => false }) + _ => false + } +} +fn dns_unique_object(v: JsonValue) -> Bool { + match v { JsonObject { members: object_members } => all(object_members, f => count(object_members.filter(other => other.key == f.key)) == 1) _ => false } +} +fn dns_attrs(v: JsonValue) -> List { + match v { JsonObject { members: object_members } => map(object_members, f => XmlAttribute { name: f.key, value: dns_string(v: v, key: f.key) }) _ => [] } +} +fn dns_attr(fields: List, name: String) -> String { + match fields.filter(f => f.name == name).first() { Present { value: f } => f.value Absent => "" } +} +fn dns_hex_revision(s: String) -> Bool { + string_length(s: s) == 40 && all(s.chars(), c => base16_is_lower_digit_code_point(cp: c)) +} +fn dns_exact_credential(s: String) -> Bool { + match resolve_returned_version(returned: s as SmResolvedVersionIdentity, ref: namecheap_api_key_secret_ref, project_number: fleet_secrets_project_number as String) { + SecretRefVersionResolvedAlias { alias: _, version: _ } => true + SecretRefVersionResolvedExact { version: _ } => true + _ => false + } +} +fn dns_observation_read(v: JsonValue, expected_run: String, expected_attempt: String, expected_revision: String) -> DnsObservationRead { + let rows = dns_field(v: v, key: "hosts") + let result = dns_field(v: v, key: "result_fields") + let hosts = dns_elements(v: rows) + if !dns_unique_object(v: v) || dns_string(v: v, key: "schema") != "gunbc-namecheap-observation/v1" + || dns_string(v: v, key: "standing") != "getHosts-observed" + || dns_string(v: v, key: "write_authority") != "withheld" || dns_string(v: v, key: "mail_mode") != "unobserved" + || expected_run == "" || expected_attempt == "" || !decimal_digits_only(s: expected_run) || !decimal_digits_only(s: expected_attempt) || !dns_hex_revision(s: expected_revision) + || dns_string(v: v, key: "run_id") != expected_run || dns_string(v: v, key: "run_attempt") != expected_attempt || dns_string(v: v, key: "revision") != expected_revision + || dns_string(v: v, key: "public_egress_ipv4") != namecheap_account_report.api_allowlist_ipv4 + || dns_string(v: v, key: "account") != namecheap_account_report.username || dns_string(v: v, key: "domain") != namecheap_account_report.domain + || !utc_instant_is_canonical(t: dns_string(v: v, key: "started_at")) || !dns_exact_credential(s: dns_string(v: v, key: "exact_credential_version")) { + DnsObservationReadRefused { reason: "Observation envelope, expected run identity, domain or exact credential version is invalid" } + } else if !dns_string_object(v: result) || dns_string(v: result, key: "Domain") != namecheap_account_report.domain || dns_string(v: result, key: "IsUsingOurDNS") != "true" + || !(match rows { JsonArray { elements: _ } => true _ => false }) + || !all(hosts, h => dns_string_object(v: h) && nc_host_valid(e: XmlElement { name: "Host", attributes: dns_attrs(v: h), children: [], text: "" }) + && count(hosts.filter(other => dns_string(v: other, key: "HostId") == dns_string(v: h, key: "HostId"))) == 1) { + DnsObservationReadRefused { reason: "Observation provider fields or host records are incomplete or ambiguous" } + } else { + DnsObservationReadOk { observation: DnsObservation { receipt: v, run_id: expected_run, attempt: expected_attempt, revision: expected_revision, + started_at: dns_string(v: v, key: "started_at"), credential_version: dns_string(v: v, key: "exact_credential_version"), + result_fields: dns_attrs(v: result), hosts: map(hosts, h => NamecheapObservedHost { fields: dns_attrs(v: h) }) } } + } +} +// Review may include cold compilation and artifact retrieval. This 30-minute +// planning policy is not freshness admission for an eventual write under a lease. +data dns_planning_window_seconds: Int = 1800 + +fn dns_observation_fresh(o: DnsObservation, now: String) -> Bool { + utc_instant_is_canonical(t: now) && utc_instant_is_canonical(t: o.started_at) + && utc_instant_seconds_after(later: now, earlier: o.started_at) >= 0 + && utc_instant_seconds_after(later: now, earlier: o.started_at) <= dns_planning_window_seconds +} diff --git a/dag/gunbc/namecheap/planning/plan.dag b/dag/gunbc/namecheap/planning/plan.dag new file mode 100644 index 00000000000..9686c054390 --- /dev/null +++ b/dag/gunbc/namecheap/planning/plan.dag @@ -0,0 +1,125 @@ +module gunbc.namecheap.planning.plan + +import std.types { String, List, Bool } +import extdeps.languages.xml.read { XmlAttribute } +import extdeps.languages.json.emit { JsonValue, json_object, json_kv, json_string, json_array, json_bool, serialize_json } +import extdeps.dns.domain_name { fold_dns_case_string } +import extdeps.network.ipv4 { render_ipv4_address } +import gunbc.namecheap.account { namecheap_account_report } +import gunbc.namecheap.planning.observation { DnsObservation, dns_attr, dns_observation_fresh } +import gunbc.namecheap.planning.intent { DevDnsIntent, DevServiceAddress, Dns01Change, PublishChallenge, RetireChallenge, dev_dns_label, dev_dns_challenge_name, dev_dns_origin, dev_dns_service_id } +import gunbc.auth.consent_preimage { consent_field } +import std.bytes { bytes_octets, utf8_encode_bytes } +import extdeps.crypto.sha2 { sha256_hex } + +type DnsReviewPlan { + observation: DnsObservation + intent: DevDnsIntent + proposed: List> +} +type DnsPlanning = DnsReviewReady { plan: DnsReviewPlan } | DnsPlanningRefused { reason: String } + +// Service placement and DNS-01 share this zone-wide writer identity. A key is not +// a lease or a fencing token; this review slice neither acquires nor invents one. +fn dns_zone_writer_key() -> String { concat("namecheap/zone/", namecheap_account_report.domain) } +fn dns_owner_matches(fields: List, label: String) -> Bool { + let name = fold_dns_case_string(s: dns_attr(fields: fields, name: "Name")) + let fqdn = join([label, ".", namecheap_account_report.domain], "") + name == label || name == fqdn || name == concat(fqdn, ".") +} +fn dns_new_fields(name: String, kind: String, address: String) -> List { + [XmlAttribute { name: "Name", value: name }, XmlAttribute { name: "Type", value: kind }, + XmlAttribute { name: "Address", value: address }, XmlAttribute { name: "TTL", value: "300" }] +} +fn dns_service_conflict(rows: List>, s: DevServiceAddress) -> Bool { + let owned = rows.filter(r => dns_owner_matches(fields: r, label: dev_dns_label(s: s.service))) + count(owned.filter(r => dns_attr(fields: r, name: "Type") == "A")) > 1 + || owned.any(r => dns_attr(fields: r, name: "Type") != "A" && dns_attr(fields: r, name: "Type") != "TXT" && dns_attr(fields: r, name: "Type") != "CAA") +} +fn dns_service_upsert(rows: List>, s: DevServiceAddress) -> List> { + let label = dev_dns_label(s: s.service) + let address = render_ipv4_address(addr: s.ipv4) as String + let exists = rows.any(r => dns_owner_matches(fields: r, label: label) && dns_attr(fields: r, name: "Type") == "A") + if exists { + map(rows, r => if dns_owner_matches(fields: r, label: label) && dns_attr(fields: r, name: "Type") == "A" { + map(r, f => if f.name == "Address" { XmlAttribute { name: f.name, value: address } } else { f }) + } else { r }) + } else { concat(rows, [dns_new_fields(name: label, kind: "A", address: address)]) } +} +fn dns_challenge_matches(r: List, c: Dns01Change) -> Bool { + dns_owner_matches(fields: r, label: dev_dns_challenge_name(s: c.service)) && dns_attr(fields: r, name: "Type") == "TXT" && dns_attr(fields: r, name: "Address") == c.value +} +fn dns_challenge_conflict(rows: List>, c: Dns01Change) -> Bool { + count(rows.filter(r => dns_challenge_matches(r: r, c: c))) > 1 + || rows.any(r => dns_owner_matches(fields: r, label: dev_dns_challenge_name(s: c.service)) && dns_attr(fields: r, name: "Type") != "TXT") +} +fn dns_challenge_change(rows: List>, c: Dns01Change) -> List> { + match c.action { + PublishChallenge => if rows.any(r => dns_challenge_matches(r: r, c: c)) { rows } + else { concat(rows, [dns_new_fields(name: dev_dns_challenge_name(s: c.service), kind: "TXT", address: c.value)]) } + RetireChallenge => rows.filter(r => !dns_challenge_matches(r: r, c: c)) + } +} +fn dns_plan(o: DnsObservation, intent: DevDnsIntent, now: String) -> DnsPlanning { + let rows = map(o.hosts, h => h.fields) + if !dns_observation_fresh(o: o, now: now) { DnsPlanningRefused { reason: "Observation is future-dated, noncanonical or older than the 30-minute review window" } } + else if intent.services.any(s => dns_service_conflict(rows: rows, s: s)) { + DnsPlanningRefused { reason: "Managed service owner has multiple A records or unsupported/delegated address data (including AAAA); explicit reconciliation is required" } + } else if intent.challenges.any(c => dns_challenge_conflict(rows: rows, c: c)) { + DnsPlanningRefused { reason: "DNS-01 owner is delegated, conflicts with another record type or repeats the same token" } + } else { + let addresses = fold(intent.services, init: rows, f: (acc, s) => dns_service_upsert(rows: acc, s: s)) + let proposed = fold(intent.challenges, init: addresses, f: (acc, c) => dns_challenge_change(rows: acc, c: c)) + DnsReviewReady { plan: DnsReviewPlan { observation: o, intent: intent, proposed: proposed } } + } +} +fn dns_fields_json(fields: List) -> JsonValue { + json_object(members: map(fields, f => json_kv(key: f.name, value: json_string(s: f.value)))) +} +fn dns_plan_changed(p: DnsReviewPlan) -> Bool { p.proposed != map(p.observation.hosts, h => h.fields) } +// All unmet apply obligations are part of the reviewed content. Noop is about +// record changes only; it does not prove mail, TLS, reachability or deployment. +fn dns_plan_content(p: DnsReviewPlan) -> JsonValue { + json_object(members: [ + json_kv(key: "schema", value: json_string(s: "gunbc-namecheap-dns-review/v1")), + json_kv(key: "zone_writer_key", value: json_string(s: dns_zone_writer_key())), + json_kv(key: "changed", value: json_bool(b: dns_plan_changed(p: p))), + json_kv(key: "observation", value: p.observation.receipt), + json_kv(key: "intent", value: p.intent.source), + json_kv(key: "service_origins", value: json_array(elements: map(p.intent.services, s => json_object(members: [ + json_kv(key: "service", value: json_string(s: dev_dns_service_id(s: s.service))), + json_kv(key: "origin", value: json_string(s: dev_dns_origin(s: s.service))), + json_kv(key: "required_access", value: json_string(s: "tailnet-only")) + ])))), + json_kv(key: "proposed_hosts", value: json_array(elements: map(p.proposed, r => dns_fields_json(fields: r)))), + json_kv(key: "preserved_result_fields", value: dns_fields_json(fields: p.observation.result_fields)), + json_kv(key: "mail_mode", value: json_string(s: "unobserved")), + json_kv(key: "write_authority", value: json_string(s: "withheld")), + json_kv(key: "apply_requirements", value: json_array(elements: map([ + "Independently observe mail mode; never infer it from MX records", + "Verify lossless setHosts serialization for every retained provider field", + "Verify service-address assignment, tailnet policy and DNS-01 ownership evidence", + "Acquire one fenced zone writer shared by service DNS and certificate renewals", + "Reobserve the whole zone and exact credential version under that fence; refuse drift", + "Replan with all apply evidence; bind the exact subject and attempt through the existing approval app", + "Independently read back the whole zone after apply; DNS equality alone is not TLS or deployment convergence" + ], r => json_string(s: r)))), + ]) +} +fn dns_plan_preimage(p: DnsReviewPlan) -> String { + consent_field(tag: "namecheap-dns-review/v1", value: serialize_json(v: dns_plan_content(p: p))) +} +fn dns_plan_digest(p: DnsReviewPlan) -> String { + sha256_hex(message: bytes_octets(b: utf8_encode_bytes(s: dns_plan_preimage(p: p)))) +} +fn dns_plan_json(p: DnsReviewPlan) -> JsonValue { + json_object(members: [json_kv(key: "plan", value: dns_plan_content(p: p)), json_kv(key: "sha256", value: json_string(s: dns_plan_digest(p: p)))]) +} +// A future apply can consume this necessary comparison, but true is NOT an +// authorization or provider CAS. Fresh provenance may differ; zone content may not. +fn dns_review_still_matches(p: DnsReviewPlan, current: DnsObservation, intent: DevDnsIntent, now: String) -> Bool { + dns_observation_fresh(o: current, now: now) && current.credential_version == p.observation.credential_version + && current.revision == p.observation.revision && current.result_fields == p.observation.result_fields + && map(current.hosts, h => h.fields) == map(p.observation.hosts, h => h.fields) + && intent.source == p.intent.source +} diff --git a/dag/gunbc/namecheap/planning/run.dag b/dag/gunbc/namecheap/planning/run.dag new file mode 100644 index 00000000000..cba0b261955 --- /dev/null +++ b/dag/gunbc/namecheap/planning/run.dag @@ -0,0 +1,53 @@ +module gunbc.namecheap.planning.run + +import std.types { String } +import std.process { ProcessExit, ExitSuccess, exit_failure } +import extdeps.filesystem.filesystem_io { Filesystem } +import extdeps.languages.json.parse { parse_json_document, JsonDocumentParsed, JsonDocumentUnreadable } +import extdeps.languages.json.emit { serialize_json } +import gunbc.clock_read { clock_now_probed_at } +import gunbc.namecheap.planning.observation { dns_observation_read, DnsObservationReadOk, DnsObservationReadRefused } +import gunbc.namecheap.planning.intent { dns_intent_read, DevDnsIntentReadOk, DevDnsIntentReadRefused } +import gunbc.namecheap.planning.plan { DnsPlanning, DnsReviewReady, DnsPlanningRefused, dns_plan, dns_plan_json } + +// Receipt provenance comes from the caller's trusted Actions artifact retrieval; +// parsing JSON alone cannot attest that a provider was contacted. Expected run, +// attempt and revision must come from that independent retrieval, not this file. +fn dns_review_text_at(observation: String, intent: String, expected_run: String, expected_attempt: String, expected_revision: String, now: String) -> DnsPlanning { + match parse_json_document(s: observation) { + JsonDocumentUnreadable { gap: _ } => DnsPlanningRefused { reason: "Observation file is not one complete JSON document" } + JsonDocumentParsed { value: receipt } => + match dns_observation_read(v: receipt, expected_run: expected_run, expected_attempt: expected_attempt, expected_revision: expected_revision) { + DnsObservationReadRefused { reason } => DnsPlanningRefused { reason: reason } + DnsObservationReadOk { observation: o } => + match parse_json_document(s: intent) { + JsonDocumentUnreadable { gap: _ } => DnsPlanningRefused { reason: "Intent file is not one complete JSON document" } + JsonDocumentParsed { value: requested } => + match dns_intent_read(v: requested) { + DevDnsIntentReadRefused { reason } => DnsPlanningRefused { reason: reason } + DevDnsIntentReadOk { intent: i } => dns_plan(o: o, intent: i, now: now) + } + } + } + } +} +fn main(observation_path: String, intent_path: String, expected_run: String, expected_attempt: String, expected_revision: String, output_path: String) -> ProcessExit { + if output_path == "" || output_path == observation_path || output_path == intent_path { exit_failure(reason: "A distinct review output path is required") } + else { + let observed = Filesystem.Read(path: observation_path) + let requested = Filesystem.Read(path: intent_path) + if !observed.success || !requested.success { exit_failure(reason: "Could not read observation or intent file") } + else { + match clock_now_probed_at() { + Absent => exit_failure(reason: "Could not read the planning clock") + Present { value: now } => + match dns_review_text_at(observation: observed.content, intent: requested.content, expected_run: expected_run, expected_attempt: expected_attempt, expected_revision: expected_revision, now: now as String) { + DnsPlanningRefused { reason } => exit_failure(reason: reason) + DnsReviewReady { plan: p } => + let saved = Filesystem.Write(path: output_path, content: serialize_json(v: dns_plan_json(p: p))) + if saved.success { ExitSuccess } else { exit_failure(reason: "Could not write DNS review plan") } + } + } + } + } +} diff --git a/dag/gunbc/recurring_failure_mode/credential_guard_before_decoding.dag b/dag/gunbc/recurring_failure_mode/credential_guard_before_decoding.dag new file mode 100644 index 00000000000..1ac96f88dcc --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/credential_guard_before_decoding.dag @@ -0,0 +1,12 @@ +module gunbc.recurring_failure_mode.credential_guard_before_decoding + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data credential_guard_before_decoding: RecurringFailureMode = RecurringFailureMode { + identity: "credential_guard_before_decoding" as NonEmptyStr, + receipts: [ + "PR #12421 review 5331195485: a raw-response credential exclusion did not cover the retained XML-decoded projection. An ampersand-escaped synthetic key in Address or an unknown attribute passed the raw guard and became credential material in the JSON receipt. Invalid state: a publication verdict based on an earlier representation. Harm: credential disclosure. Discriminating facts: raw, URI-encoded and XML-entity-encoded echoes plus ordinary entities. Runtime mitigation now checks every retained result and host attribute before returning an observation and refuses without the value. Ceiling: a publication type admitting only a credential-excluded final representation; next trigger: a consumed publication authority that preserves exclusion across every subsequent transform.", + ], + evidence: [], +} diff --git a/dag/gunbc/recurring_failure_mode/receipt_consumer_without_producer.dag b/dag/gunbc/recurring_failure_mode/receipt_consumer_without_producer.dag new file mode 100644 index 00000000000..12437681cc7 --- /dev/null +++ b/dag/gunbc/recurring_failure_mode/receipt_consumer_without_producer.dag @@ -0,0 +1,12 @@ +module gunbc.recurring_failure_mode.receipt_consumer_without_producer + +import std.types { NonEmptyStr } +import gunbc.recurring_failure_mode { RecurringFailureMode } + +data receipt_consumer_without_producer: RecurringFailureMode = RecurringFailureMode { + identity: "receipt_consumer_without_producer" as NonEmptyStr, + receipts: [ + "PR #12421 review 5331195485: gunbc_ci_namecheap_observe_invoke supplied receipt_rel as though it captured execution output, but gunbc_invoke_receipt_steps only read an existing file. A successful observer therefore failed the shell step and suppressed its artifact upload. Invalid state: an emitted receipt read with no producer. Harm: successful observation becomes workflow failure. Discriminating facts: execute emitted shell in a clean root with only the declared output; a planted unmatched read must fail. Mitigation: writer, console display and uploader derive the path from one declaration. Ceiling: workflow construction rejects a receipt consumer without a producer edge; next trigger: that consumed workflow admission relation.", + ], + evidence: [], +} diff --git a/dag/test/claim/gcp_iam_converge_witness_test.dag b/dag/test/claim/gcp_iam_converge_witness_test.dag index f9c95f663ca..75625050612 100644 --- a/dag/test/claim/gcp_iam_converge_witness_test.dag +++ b/dag/test/claim/gcp_iam_converge_witness_test.dag @@ -429,3 +429,17 @@ test fn a_grant_outside_the_fleet_project_refuses_the_plan() -> Bool { _ => false } } + + +test fn namecheap_has_a_dedicated_secret_scoped_federation() -> Bool { + let matching = filter(gcp_iam_converge_targets, f => (f.service_account_id as String) == "namecheap-dns") + count(matching) == 1 && all(matching, f => + (f.pool_id as String) == "github-namecheap-dns" + && count(f.secret_grants) == 1 + && all(f.secret_grants, g => (g.target.project as String) == "gunbai-secrets" + && (g.target.secret as String) == "namecheap-api-key" + && g.role == "roles/secretmanager.secretAccessor") + && string_contains(s: f.attribute_condition, pattern: "namecheap-dns") + && string_contains(s: f.attribute_condition, pattern: "refs/heads/main") + ) +} diff --git a/dag/test/claim/namecheap_credential_witness_test.dag b/dag/test/claim/namecheap_credential_witness_test.dag new file mode 100644 index 00000000000..d4f1614519c --- /dev/null +++ b/dag/test/claim/namecheap_credential_witness_test.dag @@ -0,0 +1,22 @@ +module test.claim.namecheap_credential_witness_test +import std.types { Bool, String } +import gunbc.namecheap.credential { namecheap_credential_select, namecheap_api_key_secret_ref, NamecheapCredentialPinned, NamecheapCredentialPinRequired } + +test fn selected_version_keeps_custody_resource() -> Bool { + match namecheap_credential_select(version: "7") { + NamecheapCredentialPinned { ref } => (ref.project as String) == "gunbai-secrets" && (ref.secret as String) == "namecheap-api-key" && (ref.version as String) == "7" + _ => false + } +} +test fn aliases_cannot_bind_execution() -> Bool { + match namecheap_credential_select(version: "latest") { NamecheapCredentialPinRequired { reason: _ } => true _ => false } +} +test fn missing_version_cannot_bind_execution() -> Bool { + match namecheap_credential_select(version: "") { NamecheapCredentialPinRequired { reason: _ } => true _ => false } +} +test fn path_cannot_redirect_custody() -> Bool { + match namecheap_credential_select(version: "1/../../other") { NamecheapCredentialPinRequired { reason: _ } => true _ => false } +} +test fn zero_is_not_an_issued_version() -> Bool { + match namecheap_credential_select(version: "0") { NamecheapCredentialPinRequired { reason: _ } => true _ => false } +} diff --git a/dag/test/claim/namecheap_dns_plan_witness_test.dag b/dag/test/claim/namecheap_dns_plan_witness_test.dag new file mode 100644 index 00000000000..a2a4212fd7a --- /dev/null +++ b/dag/test/claim/namecheap_dns_plan_witness_test.dag @@ -0,0 +1,135 @@ +module test.claim.namecheap_dns_plan_witness_test + +import std.types { String, Bool } +import gunbc.namecheap.planning.run { dns_review_text_at } +import gunbc.namecheap.planning.plan { DnsPlanning, DnsReviewReady, DnsPlanningRefused, dns_plan_changed, dns_plan_preimage, dns_review_still_matches, dns_zone_writer_key } +import gunbc.namecheap.planning.observation { dns_attr } + +data dns_test_revision: String = "1111111111111111111111111111111111111111" +data dns_test_credential: String = "projects/582015116396/secrets/namecheap-api-key/versions/1" +data dns_test_token: String = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" +data dns_test_other_token: String = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBA" + +fn dns_test_receipt(rows: String, credential: String, extra_result: String) -> String { + join(["{\"schema\":\"gunbc-namecheap-observation/v1\",\"standing\":\"getHosts-observed\",\"run_id\":\"123\",\"run_attempt\":\"1\",\"revision\":\"", dns_test_revision, + "\",\"started_at\":\"2026-09-27T16:00:00Z\",\"exact_credential_version\":\"", credential, + "\",\"account\":\"briansrls\",\"public_egress_ipv4\":\"96.224.201.7\",\"domain\":\"gunb.ai\",\"result_fields\":{\"Domain\":\"gunb.ai\",\"IsUsingOurDNS\":\"true\"", extra_result, + "},\"hosts\":[", rows, "],\"mail_mode\":\"unobserved\",\"write_authority\":\"withheld\"}"], "") +} +fn dns_test_row(id: String, name: String, kind: String, address: String) -> String { + join(["{\"HostId\":\"", id, "\",\"Name\":\"", name, "\",\"Type\":\"", kind, "\",\"Address\":\"", address, "\",\"TTL\":\"1800\",\"Future\":\"preserve-me\"}"], "") +} +fn dns_test_intent(services: String, challenges: String) -> String { + join(["{\"schema\":\"gunbc-dev-dns-intent/v1\",\"services\":[", services, "],\"challenges\":[", challenges, "]}"], "") +} +fn dns_test_service(ip: String) -> String { + join(["{\"service\":\"svc:tracker-dev\",\"ipv4\":\"", ip, "\",\"observation_ref\":\"fixture-service-observation\"}"], "") +} +fn dns_test_challenge(action: String, value: String) -> String { + join(["{\"service\":\"svc:tracker-dev\",\"action\":\"", action, "\",\"value\":\"", value, "\",\"ownership_ref\":\"fixture-acme-order\"}"], "") +} +fn dns_test_plan(rows: String, services: String, challenges: String, now: String) -> DnsPlanning { + dns_review_text_at(observation: dns_test_receipt(rows: rows, credential: dns_test_credential, extra_result: ",\"FutureResult\":\"preserved\""), intent: dns_test_intent(services: services, challenges: challenges), + expected_run: "123", expected_attempt: "1", expected_revision: dns_test_revision, now: now) +} +fn dns_test_refused(p: DnsPlanning) -> Bool { match p { DnsPlanningRefused { reason: _ } => true _ => false } } +test fn dns_preserves_unrelated_rows_and_unknown_fields() -> Bool { + let rows = join([dns_test_row(id: "1", name: "@", kind: "A", address: "185.199.108.153"), dns_test_row(id: "2", name: "@", kind: "TXT", address: "v=DMARC1; p=none"), dns_test_row(id: "3", name: "@", kind: "MX", address: "SMTP.GOOGLE.COM.")], ",") + match dns_test_plan(rows: rows, services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:01:00Z") { + DnsReviewReady { plan: p } => count(p.proposed) == 4 && all(p.observation.hosts, h => p.proposed.any(r => r == h.fields)) + && dns_attr(fields: p.observation.result_fields, name: "FutureResult") == "preserved" + _ => false + } +} +test fn dns_existing_address_noop_retains_provider_metadata() -> Bool { + match dns_test_plan(rows: dns_test_row(id: "1", name: "tracker-dev", kind: "A", address: "100.100.1.1"), services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => !dns_plan_changed(p: p) _ => false + } +} +test fn dns_address_change_preserves_unknown_fields_and_ttl() -> Bool { + match dns_test_plan(rows: dns_test_row(id: "1", name: "tracker-dev", kind: "A", address: "100.100.1.2"), services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => dns_plan_changed(p: p) && count(p.proposed) == 1 && all(p.proposed, r => dns_attr(fields: r, name: "Future") == "preserve-me" && dns_attr(fields: r, name: "TTL") == "1800" && dns_attr(fields: r, name: "Address") == "100.100.1.1") _ => false + } +} +test fn dns_case_folded_fqdn_cname_conflict_refuses() -> Bool { + dns_test_refused(p: dns_test_plan(rows: dns_test_row(id: "1", name: "TRACKER-DEV.GUNB.AI.", kind: "CNAME", address: "elsewhere."), services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z")) +} +test fn dns_existing_ipv6_requires_explicit_reconciliation() -> Bool { + dns_test_refused(p: dns_test_plan(rows: dns_test_row(id: "1", name: "tracker-dev", kind: "AAAA", address: "2001:db8::1"), services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z")) +} +test fn dns_multi_address_rrset_is_not_silently_replaced() -> Bool { + dns_test_refused(p: dns_test_plan(rows: join([dns_test_row(id: "1", name: "tracker-dev", kind: "A", address: "100.100.1.1"), dns_test_row(id: "2", name: "tracker-dev", kind: "A", address: "100.100.1.2")], ","), services: dns_test_service(ip: "100.100.1.3"), challenges: "", now: "2026-09-27T16:00:00Z")) +} +test fn dns_public_address_refuses() -> Bool { dns_test_refused(p: dns_test_plan(rows: "", services: dns_test_service(ip: "8.8.8.8"), challenges: "", now: "2026-09-27T16:00:00Z")) } +test fn dns_stale_observation_refuses() -> Bool { dns_test_refused(p: dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:30:01Z")) } +test fn dns_future_observation_refuses() -> Bool { dns_test_refused(p: dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T15:59:59Z")) } +test fn dns_freshness_boundary_is_inclusive() -> Bool { + match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:30:00Z") { DnsReviewReady { plan: _ } => true _ => false } +} +test fn dns_unresolved_credential_alias_refuses() -> Bool { + dns_test_refused(p: dns_review_text_at(observation: dns_test_receipt(rows: "", credential: "projects/582015116396/secrets/namecheap-api-key/versions/latest", extra_result: ""), intent: dns_test_intent(services: dns_test_service(ip: "100.100.1.1"), challenges: ""), expected_run: "123", expected_attempt: "1", expected_revision: dns_test_revision, now: "2026-09-27T16:00:00Z")) +} +test fn dns_wrong_run_identity_refuses() -> Bool { + dns_test_refused(p: dns_review_text_at(observation: dns_test_receipt(rows: "", credential: dns_test_credential, extra_result: ""), intent: dns_test_intent(services: dns_test_service(ip: "100.100.1.1"), challenges: ""), expected_run: "999", expected_attempt: "1", expected_revision: dns_test_revision, now: "2026-09-27T16:00:00Z")) +} +test fn dns01_publish_preserves_other_tokens() -> Bool { + let existing = dns_test_row(id: "1", name: "_acme-challenge.tracker-dev", kind: "TXT", address: dns_test_other_token) + match dns_test_plan(rows: existing, services: "", challenges: dns_test_challenge(action: "publish", value: dns_test_token), now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => count(p.proposed) == 2 && all(p.observation.hosts, h => p.proposed.any(r => r == h.fields)) _ => false + } +} +test fn dns01_retire_removes_only_the_named_token() -> Bool { + let rows = join([dns_test_row(id: "1", name: "_acme-challenge.tracker-dev", kind: "TXT", address: dns_test_token), dns_test_row(id: "2", name: "_acme-challenge.tracker-dev", kind: "TXT", address: dns_test_other_token)], ",") + match dns_test_plan(rows: rows, services: "", challenges: dns_test_challenge(action: "retire", value: dns_test_token), now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => count(p.proposed) == 1 && all(p.proposed, r => dns_attr(fields: r, name: "Address") == dns_test_other_token && dns_attr(fields: r, name: "Future") == "preserve-me") _ => false + } +} +test fn dns_service_and_challenge_share_one_zone_plan() -> Bool { + match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: dns_test_challenge(action: "publish", value: dns_test_token), now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => count(p.proposed) == 2 && dns_zone_writer_key() == "namecheap/zone/gunb.ai" _ => false + } +} +test fn dns_recheck_refuses_new_intent_and_stale_baseline() -> Bool { + match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.2"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: q } => dns_review_still_matches(p: p, current: p.observation, intent: p.intent, now: "2026-09-27T16:00:01Z") + && !dns_review_still_matches(p: p, current: q.observation, intent: q.intent, now: "2026-09-27T16:00:01Z") + && !dns_review_still_matches(p: p, current: p.observation, intent: p.intent, now: "2026-09-27T16:30:01Z") + && dns_plan_preimage(p: p) != dns_plan_preimage(p: q) + _ => false + } _ => false + } +} + +test fn dns_duplicate_or_contradictory_challenge_intent_refuses() -> Bool { + dns_test_refused(p: dns_test_plan(rows: "", services: "", challenges: join([dns_test_challenge(action: "publish", value: dns_test_token), dns_test_challenge(action: "retire", value: dns_test_token)], ","), now: "2026-09-27T16:00:00Z")) +} +test fn dns_delegated_challenge_owner_refuses() -> Bool { + dns_test_refused(p: dns_test_plan(rows: dns_test_row(id: "1", name: "_acme-challenge.tracker-dev", kind: "CNAME", address: "validation.other."), services: "", challenges: dns_test_challenge(action: "publish", value: dns_test_token), now: "2026-09-27T16:00:00Z")) +} +test fn dns_backend_host_is_not_an_ingress_identity() -> Bool { + dns_test_refused(p: dns_test_plan(rows: "", services: "{\"service\":\"svc:tracker-dev\",\"ipv4\":\"100.100.1.1\",\"observation_ref\":\"fixture\",\"host\":\"srv2\"}", challenges: "", now: "2026-09-27T16:00:00Z")) +} +test fn dns_duplicate_json_field_refuses() -> Bool { + dns_test_refused(p: dns_test_plan(rows: "", services: "{\"service\":\"svc:tracker-dev\",\"ipv4\":\"100.100.1.1\",\"ipv4\":\"8.8.8.8\",\"observation_ref\":\"fixture\"}", challenges: "", now: "2026-09-27T16:00:00Z")) +} +test fn dns_review_binds_exact_credential_generation() -> Bool { + match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => match dns_review_text_at(observation: dns_test_receipt(rows: "", credential: "projects/582015116396/secrets/namecheap-api-key/versions/2", extra_result: ",\"FutureResult\":\"preserved\""), intent: dns_test_intent(services: dns_test_service(ip: "100.100.1.1"), challenges: ""), expected_run: "123", expected_attempt: "1", expected_revision: dns_test_revision, now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: q } => dns_plan_preimage(p: p) != dns_plan_preimage(p: q) && !dns_review_still_matches(p: p, current: q.observation, intent: p.intent, now: "2026-09-27T16:00:01Z") + _ => false + } _ => false + } +} +test fn dns_review_binds_unknown_provider_fields() -> Bool { + match dns_test_plan(rows: "", services: dns_test_service(ip: "100.100.1.1"), challenges: "", now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: p } => match dns_review_text_at(observation: dns_test_receipt(rows: "", credential: dns_test_credential, extra_result: ",\"FutureResult\":\"changed\""), intent: dns_test_intent(services: dns_test_service(ip: "100.100.1.1"), challenges: ""), expected_run: "123", expected_attempt: "1", expected_revision: dns_test_revision, now: "2026-09-27T16:00:00Z") { + DnsReviewReady { plan: q } => dns_plan_preimage(p: p) != dns_plan_preimage(p: q) && !dns_review_still_matches(p: p, current: q.observation, intent: p.intent, now: "2026-09-27T16:00:01Z") + _ => false + } _ => false + } +} + +test fn dns01_invalid_digest_padding_bits_refuse() -> Bool { + dns_test_refused(p: dns_test_plan(rows: "", services: "", challenges: dns_test_challenge(action: "publish", value: "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"), now: "2026-09-27T16:00:00Z")) +} diff --git a/dag/test/claim/namecheap_hosts_witness_test.dag b/dag/test/claim/namecheap_hosts_witness_test.dag new file mode 100644 index 00000000000..e4506caf9fe --- /dev/null +++ b/dag/test/claim/namecheap_hosts_witness_test.dag @@ -0,0 +1,66 @@ +module test.claim.namecheap_hosts_witness_test +import std.types { String, Bool } +import extdeps.namecheap.read_hosts { namecheap_read_hosts, NamecheapHostsObserved, NamecheapHostsRefused } +import extdeps.languages.xml.read { read_xml_subset, XmlRefused, XmlParsed } + +fn response(result: String) -> String { + join(["namecheap.domains.dns.getHosts", result, ""], "") +} +fn result(domain: String, rows: String) -> String { + join(["", rows, ""], "") +} +test fn valid_host_preserves_unknown_provider_fields() -> Bool { + match namecheap_read_hosts(body: response(result: result(domain: "gunb.ai", rows: "")), domain: "gunb.ai") { + NamecheapHostsObserved { domain: observed_domain, hosts, result_fields: _ } => observed_domain == "gunb.ai" && count(hosts) == 1 && all(hosts, h => h.fields.any(f => f.name == "Future" && f.value == "preserved") && h.fields.any(f => f.name == "Address" && f.value == "a&b")) + _ => false + } +} +test fn wrong_domain_refuses() -> Bool { + match namecheap_read_hosts(body: response(result: result(domain: "other.ai", rows: "")), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} +test fn incomplete_host_refuses() -> Bool { + match namecheap_read_hosts(body: response(result: result(domain: "gunb.ai", rows: "")), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} +test fn duplicate_attributes_refuse() -> Bool { + match read_xml_subset(s: "") { XmlRefused => true _ => false } +} +test fn extra_document_refuses() -> Bool { + match read_xml_subset(s: "") { XmlRefused => true _ => false } +} +test fn mismatched_close_refuses() -> Bool { + match read_xml_subset(s: "") { XmlRefused => true _ => false } +} +test fn doctype_and_external_entity_refuse() -> Bool { + match read_xml_subset(s: "]>&x;") { XmlRefused => true _ => false } +} +test fn encoded_markup_is_text_not_a_second_host() -> Bool { + match namecheap_read_hosts(body: response(result: result(domain: "gunb.ai", rows: "<Host Name='fake'/>")), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} +test fn nested_namespace_override_refuses() -> Bool { + match namecheap_read_hosts(body: response(result: ""), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} +test fn duplicate_results_refuse() -> Bool { + match namecheap_read_hosts(body: response(result: concat(result(domain: "gunb.ai", rows: ""), result(domain: "gunb.ai", rows: ""))), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} + +test fn malformed_declaration_refuses() -> Bool { + match read_xml_subset(s: "") { XmlRefused => true _ => false } +} +test fn unsupported_entity_refuses() -> Bool { + match read_xml_subset(s: "&unknown;") { XmlRefused => true _ => false } +} +test fn literal_attribute_newline_refuses_instead_of_misnormalizing() -> Bool { + match read_xml_subset(s: "") { XmlRefused => true _ => false } +} +test fn duplicate_provider_ids_refuse() -> Bool { + let host = "" + match namecheap_read_hosts(body: response(result: result(domain: "gunb.ai", rows: concat(host, host))), domain: "gunb.ai") { NamecheapHostsRefused { reason: _ } => true _ => false } +} + + +test fn provider_result_fields_are_not_discarded() -> Bool { + match namecheap_read_hosts(body: response(result: ""), domain: "gunb.ai") { + NamecheapHostsObserved { domain: _, hosts: _, result_fields } => result_fields.any(f => f.name == "EmailType" && f.value == "MX") + _ => false + } +} diff --git a/dag/test/claim/namecheap_publication_witness_test.dag b/dag/test/claim/namecheap_publication_witness_test.dag new file mode 100644 index 00000000000..648b96a9562 --- /dev/null +++ b/dag/test/claim/namecheap_publication_witness_test.dag @@ -0,0 +1,47 @@ +module test.claim.namecheap_publication_witness_test + +import std.types { String, Secret, Bool } +import extdeps.namecheap.client { namecheap_read_hosts_for_publication } +import extdeps.namecheap.read_hosts { NamecheapHostsRead, NamecheapHostsObserved, NamecheapHostsRefused } +import test.claim.namecheap_hosts_witness_test { response } + +data fixture_key: Secret = "fixture-only-not-a-key&value" as Secret +data fixture_key_wire: String = "fixture-only-not-a-key%26value" + +fn publication(result: String) -> NamecheapHostsRead { + namecheap_read_hosts_for_publication(body: response(result: result), domain: "gunb.ai", credential: fixture_key, key_wire: fixture_key_wire) +} +fn echo_result(address: String, extra_host: String, extra_result: String) -> String { + join([""], "") +} +fn refused_without_echo(result: NamecheapHostsRead, decoded: Bool) -> Bool { + match result { + NamecheapHostsRefused { reason } => reason == if decoded { "Namecheap decoded response contains credential material; refusing to publish it" } else { "Namecheap response contains credential material; refusing to publish it" } + _ => false + } +} +test fn raw_credential_echo_refuses() -> Bool { + refused_without_echo(result: publication(result: echo_result(address: fixture_key as String, extra_host: "", extra_result: "")), decoded: false) +} +test fn uri_encoded_credential_echo_refuses() -> Bool { + refused_without_echo(result: publication(result: echo_result(address: fixture_key_wire, extra_host: "", extra_result: "")), decoded: false) +} +test fn xml_encoded_address_credential_echo_refuses() -> Bool { + refused_without_echo(result: publication(result: echo_result(address: "fixture-only-not-a-key&value", extra_host: "", extra_result: "")), decoded: true) +} +test fn xml_encoded_unknown_host_credential_echo_refuses() -> Bool { + refused_without_echo(result: publication(result: echo_result(address: "ordinary", extra_host: "Future='fixture-only-not-a-key&value'", extra_result: "")), decoded: true) +} +test fn xml_encoded_result_credential_echo_refuses() -> Bool { + refused_without_echo(result: publication(result: echo_result(address: "ordinary", extra_host: "", extra_result: "Future='fixture-only-not-a-key&value'")), decoded: true) +} +test fn ordinary_entities_remain_complete() -> Bool { + match publication(result: echo_result(address: "a&b", extra_host: "Future='<ordinary>'", extra_result: "Future='"ordinary"'")) { + NamecheapHostsObserved { domain: observed_domain, hosts, result_fields } => observed_domain == "gunb.ai" && count(hosts) == 1 + && all(hosts, h => h.fields.any(f => f.name == "Address" && f.value == "a&b") && h.fields.any(f => f.name == "Future" && f.value == "")) + && result_fields.any(f => f.name == "Future" && f.value == "\"ordinary\"") + _ => false + } +} diff --git a/dag/test/claim/namecheap_workflow_witness_test.dag b/dag/test/claim/namecheap_workflow_witness_test.dag new file mode 100644 index 00000000000..b0a36189f93 --- /dev/null +++ b/dag/test/claim/namecheap_workflow_witness_test.dag @@ -0,0 +1,45 @@ +module test.claim.namecheap_workflow_witness_test + +import std.types { Bool, String } +import v2.std.optional { Present, Absent } +import extdeps.github.actions { Step, RunStep, UsesStep } +import extdeps.languages.yaml.types { YamlString } +import gunbc.fleet_converge_workflow { + NamecheapObserve, FleetSshKeyNotConsumed, + fleet_converge_mode_options, fleet_converge_mode_fleet_ssh_key_demand, + fleet_converge_namecheap_observe_job, fleet_converge_shared_job_if, + fleet_converge_checkout_ref_expr, fleet_converge_event_sha_checkout_ref_expr, +} +import gunbc.ci_spec { gunbc_run_step_targets } +import gunbc.namecheap.observe { namecheap_observe_ci_wet } +import gunbc.namecheap.federation { namecheap_service_account, namecheap_provider_resource } + +fn auth_step_matches(st: Step) -> Bool { + match st { + UsesStep { name: _, id, uses: _, with: args, env: _, if_condition: _, continue_on_error: _, timeout_minutes: _ } => + match id { Present { value: value } => value == "wif_auth" && match args { + Present { value: fields } => any(fields, f => f.key == "service_account" && match f.value { YamlString { value: v } => v == (namecheap_service_account as String) _ => false }) + && any(fields, f => f.key == "workload_identity_provider" && match f.value { YamlString { value: v } => v == namecheap_provider_resource() _ => false }) + Absent => false + } Absent => false } + _ => false + } +} +test fn separate_job_uses_dedicated_identity() -> Bool { + let job = fleet_converge_namecheap_observe_job() + job.id == "namecheap-observe" && match job.environment { Present { value } => value == "namecheap-dns" Absent => false } + && count(filter(job.steps, s => auth_step_matches(st: s))) == 1 + && any(job.needs, n => n == "build") +} +test fn observer_has_no_fleet_ssh_demand() -> Bool { + match fleet_converge_mode_fleet_ssh_key_demand(mode: NamecheapObserve) { FleetSshKeyNotConsumed => true _ => false } +} +test fn trusted_code_cannot_be_replaced_by_dispatch_revision() -> Bool { + string_contains(s: fleet_converge_checkout_ref_expr, pattern: "github.event.inputs.mode == 'namecheap_observe'") + && fleet_converge_event_sha_checkout_ref_expr == "${{ github.sha }}" + && string_contains(s: fleet_converge_shared_job_if, pattern: "github.event.inputs.mode != 'namecheap_observe'") +} +test fn observer_is_a_registered_dispatch_and_run_target() -> Bool { + any(fleet_converge_mode_options, m => m == "namecheap_observe") + && count(filter(gunbc_run_step_targets(), t => t.entry == "dag/gunbc/namecheap/observe.dag" && t.function == "namecheap_observe_ci_wet")) == 1 +} diff --git a/docs/plans/namecheap-dns-review.md b/docs/plans/namecheap-dns-review.md new file mode 100644 index 00000000000..27f1684fcb2 --- /dev/null +++ b/docs/plans/namecheap-dns-review.md @@ -0,0 +1,135 @@ +# Namecheap DNS review planner + +This is the review-only consumer of `gunbc-namecheap-observation/v1`. It produces +one complete before/proposed zone review for service addresses and DNS-01 +challenge changes. It reads files and a clock, and writes a review file. It does +not retrieve a secret, call Namecheap or Tailscale, file an approval, acquire a +lease, change DNS, or claim deployment convergence. + +The earlier `work/daily-end-to-end` `dev_dns_convergence` work listed pending +requirements but had no receipt consumer. This planner implements that next +step against the current observation authority rather than copying its older +record representation or credential onboarding. The private network strategy +was consulted: ingress identity, backend placement, and outbound connectivity +remain separate. The two application names remain tailnet-only requirements. + +## Inputs and provenance + +`gunbc.namecheap.planning.run.main` accepts `observation_path`, `intent_path`, +`expected_run`, `expected_attempt`, `expected_revision`, and `output_path`. +The expected identity must come from trusted Actions artifact retrieval; +reading these values from the same JSON and passing them back is not independent +verification. File parsing alone never attests a provider call. The entry checks +schema, run identity, account, domain, reported egress, exact numeric Secret +Manager resource, timestamp, provider result fields, and complete unique host +records. Unknown provider fields remain attached, and the entire original +receipt is carried into the review. + +The fixed review freshness window is 30 minutes, inclusive, with future-dated +and noncanonical timestamps refused. This permits artifact retrieval and cold +compiler startup. It is NOT an eventual apply freshness policy: apply must +observe under the acquired writer fence immediately before its re-admission. + +Intent schema (addresses and evidence references below are examples, NOT observed +service assignments): + +```json +{ + "schema": "gunbc-dev-dns-intent/v1", + "services": [ + {"service": "svc:tracker-dev", "ipv4": "100.100.1.1", "observation_ref": ""}, + {"service": "svc:approvals-dev", "ipv4": "100.100.1.2", "observation_ref": ""} + ], + "challenges": [] +} +``` + +`DevDnsService` is the single authority for each relative owner and service ID. +The zone is derived from `namecheap_account_report`; no backend host is an intent +field. The planner accepts IPv4 candidates in the shared-address range only. +A matching address is NOT proof of TailVIP assignment, a service advertisement, +or tailnet authorization. Evidence references are review inputs and remain +unverified by this offline planner. Existing AAAA at a managed owner refuses; +IPv6 intent support awaits the repository's IPv6 text codec. CNAME, NS, ALIAS, +other unsupported owner data, and multiple A records also refuse rather than +silently removing conflicting records. Existing TXT/CAA at the service owner +are retained. Case and fully qualified owner spellings are compared using the +existing DNS case-folding authority while original provider fields are preserved. + +A challenge entry has exactly `service`, `action` (`publish` or `retire`), `value` +(the DNS-01 TXT digest), and `ownership_ref` (the ACME order/ownership evidence). +It changes only that exact TXT value under `_acme-challenge.`. +Other TXT values survive. A delegated challenge owner refuses; this slice does +not follow delegations. Duplicate or contradictory intents refuse. A reference +is not proof of ownership: retirement must be re-admitted against verified +ownership evidence before a future writer can act. The TXT shape validator is +owned by `extdeps.acme.dns01`, citing RFC 8555 section 8.4. + +## Output and future approval binding + +The JSON result contains `plan` and `sha256`. The plan carries the complete +original observation, intent, proposed host fields, unchanged result fields, +record-change/no-op standing, and shared zone writer key `namecheap/zone/gunb.ai`. +Existing record order and fields are preserved. Address updates change only +`Address`; existing TTL and unknown metadata survive. New records use TTL 300. +Identical input yields identical content, and an already satisfied address/token +is a record no-op. No-op does not assert mail, TLS or deployment convergence. + +The digest is SHA-256 over the existing injective `consent_field` framing of the +actual serialized plan content. It binds the observation identity and numeric +credential version, every retained provider field, requested evidence references, +proposed records, and remaining apply obligations. This is deterministic for +identical inputs, not a claim that differently ordered equivalent JSON has one +digest; conservative ordering changes can require renewed review. + +`dns_review_still_matches` checks a fresh observation against the frozen provider +fields, program revision, credential generation and intent. It rejects drift, +including changes in unknown provider fields. A true result is a necessary +comparison only, not authorization, a lease, or provider CAS. The eventual +mutation must reuse `std.scoped_authorization` and `gunbc.auth.approval_gate`, +with the exact admitted subject and attempt. This planner deliberately does not +file a premature mutation request while its apply requirements remain open. + +`mail_mode=unobserved` and `write_authority=withheld` are preserved. Before apply: +independently observe mail mode; prove a lossless mapping of all retained fields +to setHosts (unknown fields cannot be silently dropped); verify service/policy +and ACME ownership evidence; acquire one fenced writer for ALL zone changes; +reobserve under that fence with the pinned credential; present the fully admitted +plan through the existing approval app; and independently read back afterward. +The provider offers whole-zone replacement, not a CAS over this observation. +Console/API writers outside the fence remain a coordination prerequisite. + +## Running and controls + +With a current gunbc binary under an enforced memory scope, run: + +```sh +systemd-run --user --scope -p MemoryMax=26G --quiet \ + /absolute/path/to/gunbc run --source-root dag --source-root src/v2 \ + --entry dag/gunbc/namecheap/planning/run.dag \ + --arg observation_path=/path/to/namecheap-observation.json \ + --arg intent_path=/path/to/dns-intent.json \ + --arg expected_run=RUN_ID --arg expected_attempt=ATTEMPT \ + --arg expected_revision=OBSERVATION_PROGRAM_SHA \ + --arg output_path=/path/to/dns-review.json +``` + +A failure exits nonzero and does not publish a new plan. A pre-existing output +file may still exist; consumers must require success and check the bound input +identity rather than treating file existence as a new receipt. + +The `.dag` controls are `test.claim.namecheap_dns_plan_witness_test`. The offline +file-path test is `test/namecheap/planner_files.py`: it runs the real file entry, +checks preservation and combined service/challenge output, verifies SHA-256 with +an independent Python oracle, and plants a mismatched run identity. It uses only +synthetic provider data, not a live credential or authenticated observation. + +Authorities: [Namecheap setHosts](https://www.namecheap.com/support/api/methods/domains-dns/set-hosts/), +[Tailscale Services](https://tailscale.com/docs/features/tailscale-services), +[ACME DNS-01](https://www.rfc-editor.org/rfc/rfc8555#section-8.4). + +Local validation: all 23 planner controls passed. The real file entry passed the +preservation, stable-origin, combined DNS/DNS-01, independent digest and wrong-run +controls. The existing query-transport control also passed after its import +closure staging helper was shared with the planner control. Repository-wide +qualification remains the exact-head CI run on the follow-on PR. diff --git a/docs/plans/namecheap-secret-manager.md b/docs/plans/namecheap-secret-manager.md new file mode 100644 index 00000000000..d2063a4a638 --- /dev/null +++ b/docs/plans/namecheap-secret-manager.md @@ -0,0 +1,135 @@ +# Namecheap credential custody + +The owner reports `projects/582015116396/secrets/namecheap-api-key` provisioned. +The project number matches `fleet_secrets_project_number`; the project ID comes +from `fleet_secrets_gcp_project` (`gunbai-secrets`). No new version is provisioned +by this lane, and no key is requested in chat or in a persistent local file. + +The owner also supplied account `briansrls` and an API allowlist entry named `dev` +for `96.224.201.7`. These are typed operator reports in +`gunbc.namecheap.account`, not evidence of an authenticated API call or a +permanent DNS-controller placement. + +`gunbc.namecheap.credential` binds the container once. Its `latest` reference identifies the +container for IAM and is resolved once by the discovery observer, which records +the returned numeric version. The numeric selector controls apply to future +reviewed mutation consumers, not this discovery path: an approval over a DNS +mutation must not silently switch credential generations through an alias. `gunbc.namecheap.credential_read` uses the existing +`fetch_secret_ref_credential` with `WorkloadIdentityToken`; the shared reader +checks the response resource against the requested project, secret and version. +It does not print or persist the payload. + +The dedicated `namecheap-dns` federation joins `gcp_iam_converge_targets`. Its +trust is pinned by the shared fleet job claim authority to this repository, the +fleet-converge workflow at main, a dispatch on main, and the `namecheap-dns` +environment. Its only secret grant is accessor on `namecheap-api-key`. The +existing IAM workflow plans as iam-observe, files a request in the approval app, +impersonates iam-converge only after approval, rechecks the approved plan, applies +and independently reads back. Enrollment is not evidence of an applied grant. +The new target also changes the resource-local bootstrap bindings needed by the +IAM controller; those must be observed before running its apply. +This preserves the repo's distinction between routine per-secret federated +reads and operation-specific human approvals. For DNS mutations, the consumer +must use the existing `approval_gate` and scoped authorization over the frozen +zone plan; secret possession alone never authorizes a zone replacement. This +increment does not yet connect that DNS mutation consumer. + +Live status: secret creation and the allowlist are operator-reported. No enabled +version or applied IAM binding has been independently observed. This local shell +has no WIF token or gcloud installation. No provider request, IAM write, key +access, or DNS mutation has been executed. The `namecheap_observe` fleet mode now has a dedicated job that supplies WIF +and records the exact secret version plus authenticated getHosts readback. It +still must land on main and its federation must converge before a live dispatch +can pass the provider's main-only claim pins. +The earlier DNS model lives on `work/daily-end-to-end`; this lane is based on the +current repository so it can reuse the landed approval app rather than copying +its newer authorities into that older checkout. The previous local `api_key_file` +onboarding proposal is superseded by this Secret Manager reference. + +Validation on the current main base: a combined full-root run evaluated all +21 IAM convergence controls, five credential controls, 15 XML/provider controls, +and four workflow controls successfully, then regenerated the workflow through +`tools.generated_artifact_gate.main_wet_one`. Its receipt counts 46 because it +also ran one redundant aggregate IAM check, subsequently removed; the retained +45 controls all ran in that invocation. The generated YAML was independently +parsed and checked for the dispatch mode, dedicated identity, event SHA checkout, +SSH exclusion, and success-only receipt upload. The self-contained loopback +transport harness passed and observed the fixture query arriving without the +fixture key in curl's argv. These are local checks, not live GCP or Namecheap +observations. + + +## Read-only verification workflow + +`fleet-converge` mode `namecheap_observe` uses the selected fleet runner and the +`namecheap-dns` environment. The build and observer both check out the event SHA; +`expected_revision` cannot substitute another revision under the trusted WIF +identity. The shared fleet job is excluded, and this job receives no SSH key. + +The entry checks public IPv4 egress against the owner-reported allowlist, fetches +one Secret Manager version through the shared checked reader, and issues only +`namecheap.domains.dns.getHosts`. The encoded query travels to curl via stdin, +not argv or a persistent credential file. There is no setter or configurable +command in this provider interface. Every read has a timeout. Transport and +provider refusals omit raw response bodies, and a response that contains the +credential is withheld before decoding; the decoded retained result and host +attributes are checked again before the observation can reach the receipt. + +The XML subset reader refuses unsupported syntax and ambiguous envelopes rather +than guessing: DTDs and external entities, duplicate attributes, extra documents, +namespace overrides, malformed records, duplicate provider record IDs, and a +response for another domain/command cannot yield an observation. It preserves +all provider result and host attributes, including unknown ones. Unsupported XML features +(including numeric character references) are a located read refusal; this is not +a claim to implement every XML document. + +`target/namecheap-observation.json` records the run ID, attempt, revision, start +time, exact credential version, account, observed public egress, domain and +returned result and host fields. The job uploads it only after success, so failure cannot +publish a previous run's receipt. `mail_mode=unobserved` and +`write_authority=withheld` are deliberate: getHosts does not independently prove +the console's mail mode, and this observer grants no DNS mutation authority. + +The remaining live sequence is: land the reviewed code on main; observe/update +the existing IAM controller's resource-local bootstrap reach for the new target; +run `gcp_iam_converge` and approve its exact plan in the existing app; then run +`namecheap_observe` on a runner whose public egress is allowlisted. Secret +possession, a modeled grant, and a pure witness are not substitutes for those +readbacks. Full-zone DNS mutation, DNS-01 renewal coordination, and dashboard +fleet cutover remain separate unfinished consumers of this observation. + +Provider authority: [Namecheap getHosts](https://www.namecheap.com/support/api/methods/domains-dns/get-hosts/). + +Landing boundary: the active main ruleset requires the `witnesses` status and +merge queue. The IAM authority also requires reviewed main code; the feature +branch cannot impersonate the dedicated main-pinned identity. A read-only query +of the latest 100 fleet workflow dispatches found no `gcp_iam_converge` run. That +is limited history, not proof that bootstrap never happened. The bootstrap +reach and independent readback are still unverified. + +## Review 5331195485 corrections + +The writer, generated console display and artifact uploader now consume +`gunbc.namecheap.observation_artifact.namecheap_observation_receipt_path`. The +workflow shell control reproduces the original unmatched receipt read on the old +generated YAML, then exercises the regenerated success path with only the +declared JSON output. Its planted unmatched read must fail. + +The production client keeps the raw response exclusion and also checks all +decoded retained result and host attribute names and values, including unknown +fields, before returning an observation. Six publication controls cover raw, +URI-encoded, and XML-entity-encoded echoes plus ordinary entity preservation. +Disabling the decoded guard in an isolated test copy makes the three XML echo +controls fail while raw/URI and ordinary-value controls still pass. Refusals +contain no offending value and never claim a partially redacted snapshot. + +The previous head's CI floor identified bare algebra-provider references and an +ambiguous shorthand `domain` binder. List operations now use their native method +forms, and the observer uses an explicit binder. Uppercase `Host` remains unchanged. + +Correction validation: the combined full-root run passed 51 controls and +regenerated the workflow. After the CI-reference corrections, all 15 parser and +six publication controls passed again. The regenerated emitted shell passed the +clean-root success control, left the exact upload artifact, and rejected the +planted unmatched receipt read. `git diff --check` passed. Exact-head CI is the +remaining repository-wide check; no live provider or IAM actuation was used. diff --git a/test/namecheap/README.md b/test/namecheap/README.md new file mode 100644 index 00000000000..d6cbabf5cec --- /dev/null +++ b/test/namecheap/README.md @@ -0,0 +1,28 @@ +# Namecheap transport control + +Run from the repository root with a current gunbc binary and an enforced memory +limit (the parser/compiler refuses an unbounded host budget): + +```sh +systemd-run --user --scope -p MemoryMax=6G --quiet \ + python3 test/namecheap/query_transport.py /absolute/path/to/gunbc +``` + +The harness stages its own temporary import closure, starts a loopback HTTP +server, and sends a fixture-only query through the actual +modeled `Http.Client.GetQueryStdinWithin` operation, verifies the received query, +and inspects that curl process's argv to ensure the fixture key is absent. It +uses no real credential and reaches neither GCP nor Namecheap. + +After regenerating the workflow, run `python3 test/namecheap/workflow_success.py` +(requires PyYAML). It executes the emitted shell from a clean temporary root with +a successful observer stand-in producing only the declared JSON artifact, checks +the exact upload path, and confirms a planted unmatched receipt read fails. It +does not substitute for a live GCP or Namecheap observation. + +Run `systemd-run --user --scope -p MemoryMax=6G --quiet python3 + test/namecheap/planner_files.py /absolute/path/to/gunbc` on one shell line for +the offline DNS planner file-path control. It stages its import closure, checks +preservation of website/mail/unknown fields, plans both service names and a +DNS-01 addition, verifies the emitted SHA-256 independently, and checks a +mismatched run refuses. No live provider or secret access occurs. diff --git a/test/namecheap/planner_files.py b/test/namecheap/planner_files.py new file mode 100644 index 00000000000..1ec3633b616 --- /dev/null +++ b/test/namecheap/planner_files.py @@ -0,0 +1,66 @@ +"""Exercise the real planner file entry point with synthetic, offline inputs.""" +import datetime +import hashlib +import json +import pathlib +import subprocess +import sys +import tempfile + +from support import stage_import_closure + +root = pathlib.Path.cwd() +binary = sys.argv[1] +scratch_root = root / 'target/namecheap-tests' +scratch_root.mkdir(parents=True, exist_ok=True) +with tempfile.TemporaryDirectory(prefix='planner-', dir=scratch_root) as temp: + work = pathlib.Path(temp) + closure = work / 'closure' + entry = stage_import_closure(root / 'dag/gunbc/namecheap/planning/run.dag', closure) + observation = work / 'observation.json' + intent_file = work / 'intent.json' + output = work / 'review.json' + revision = '1' * 40 + rows = [ + {'HostId': '1', 'Name': '@', 'Type': 'A', 'Address': '185.199.108.153', 'TTL': '1800', 'Future': 'retained'}, + {'HostId': '2', 'Name': '@', 'Type': 'MX', 'Address': 'SMTP.GOOGLE.COM.', 'MXPref': '1', 'TTL': '1800'}, + {'HostId': '3', 'Name': '@', 'Type': 'TXT', 'Address': 'v=DMARC1; p=none', 'TTL': '1800'}, + ] + receipt = {'schema': 'gunbc-namecheap-observation/v1', 'standing': 'getHosts-observed', + 'run_id': '123', 'run_attempt': '1', 'revision': revision, + 'started_at': datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'), + 'exact_credential_version': 'projects/582015116396/secrets/namecheap-api-key/versions/1', + 'account': 'briansrls', 'public_egress_ipv4': '96.224.201.7', 'domain': 'gunb.ai', + 'result_fields': {'Domain': 'gunb.ai', 'IsUsingOurDNS': 'true', 'FutureResult': 'retained'}, + 'hosts': rows, 'mail_mode': 'unobserved', 'write_authority': 'withheld'} + intent = {'schema': 'gunbc-dev-dns-intent/v1', 'services': [ + {'service': 'svc:tracker-dev', 'ipv4': '100.100.1.1', 'observation_ref': 'fixture-only/tracker'}, + {'service': 'svc:approvals-dev', 'ipv4': '100.100.1.2', 'observation_ref': 'fixture-only/approvals'}], + 'challenges': [{'service': 'svc:tracker-dev', 'action': 'publish', 'value': 'A' * 43, 'ownership_ref': 'fixture-only/order'}]} + observation.write_text(json.dumps(receipt)) + intent_file.write_text(json.dumps(intent)) + command = [binary, 'run', '--source-root', str(closure), '--entry', str(entry)] + for key, value in dict(observation_path=observation, intent_path=intent_file, output_path=output, + expected_run='123', expected_attempt='1', expected_revision=revision).items(): + command += ['--arg', f'{key}={value}'] + result = subprocess.run(command, capture_output=True, text=True, timeout=180) + assert result.returncode == 0, result.stdout + result.stderr + saved = json.loads(output.read_text()) + plan = saved['plan'] + assert plan['observation'] == receipt and plan['intent'] == intent + assert plan['proposed_hosts'][:3] == rows and len(plan['proposed_hosts']) == 6 + assert plan['preserved_result_fields'] == receipt['result_fields'] + assert plan['write_authority'] == 'withheld' and plan['mail_mode'] == 'unobserved' + assert [s['origin'] for s in plan['service_origins']] == ['https://tracker-dev.gunb.ai', 'https://approvals-dev.gunb.ai'] + assert all(s['required_access'] == 'tailnet-only' for s in plan['service_origins']) + assert plan['zone_writer_key'] == 'namecheap/zone/gunb.ai' + # Independent digest oracle for the actual emitted content, using its framing. + content = json.dumps(plan, ensure_ascii=False, separators=(', ', ': ')) + preimage = f'namecheap-dns-review/v1:{len(content)}:{content};' + assert saved['sha256'] == hashlib.sha256(preimage.encode()).hexdigest() + # Same file entry refuses an observation with mismatched independently supplied provenance. + command[command.index('expected_run=123')] = 'expected_run=999' + refused = subprocess.run(command, capture_output=True, text=True, timeout=180) + assert refused.returncode != 0 + assert json.loads(output.read_text()) == saved # refusal does not publish a new plan +print('Planner file path passed: preserved snapshot, combined plan, SHA-256 oracle, wrong-run refusal') diff --git a/test/namecheap/query_transport.py b/test/namecheap/query_transport.py new file mode 100644 index 00000000000..efa4c2673bd --- /dev/null +++ b/test/namecheap/query_transport.py @@ -0,0 +1,72 @@ +"""Exercise the modeled GET query transport against loopback, using a fake key.""" +import http.server +import json +import pathlib +import tempfile +import subprocess +import sys +import threading +import urllib.parse + +root = pathlib.Path.cwd() +binary = sys.argv[1] +fixture = 'fixture-only-not-a-key&value' +observed = {} + + +from support import stage_import_closure + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + observed['query'] = urllib.parse.parse_qs(urllib.parse.urlsplit(self.path).query) + observed['argv_exposed'] = False + for proc in pathlib.Path('/proc').iterdir(): + if not proc.name.isdigit(): + continue + try: + args = (proc / 'cmdline').read_bytes().split(b'\0') + if args and pathlib.Path(args[0].decode()).name == 'curl' and any(url.encode() == a for a in args): + observed['curl_seen'] = True + observed['argv_exposed'] |= any(b'fixture-only-not-a-key' in a for a in args) + except (OSError, UnicodeError): + pass + self.send_response(200) + self.end_headers() + self.wfile.write(b'fixture-response') + def log_message(self, *args): + pass + +server = http.server.HTTPServer(('127.0.0.1', 0), Handler) +url = f'http://127.0.0.1:{server.server_port}/probe' +thread = threading.Thread(target=server.serve_forever, daemon=True) +thread.start() +scratch_root = root / 'target/namecheap-tests' +scratch_root.mkdir(parents=True, exist_ok=True) +scratch = tempfile.TemporaryDirectory(prefix='query-', dir=scratch_root) +entry = pathlib.Path(scratch.name) / 'query_transport_test.dag' +entry.write_text('''module test.namecheap.query_transport_probe +import std.types { Bool, String, NonEmptyStr } +import extdeps.http.client + +test fn query_reaches_server(url: String) -> Bool { + let result = http.Client.GetQueryStdinWithin(url: url as NonEmptyStr, + query: "ApiKey=fixture-only-not-a-key%26value&Command=namecheap.domains.dns.getHosts", + connect_seconds: "2" as NonEmptyStr, max_seconds: "5" as NonEmptyStr) + result.success && result.body == "fixture-response\\n200" +} +''') +try: + closure = pathlib.Path(scratch.name) / 'closure' + staged_entry = stage_import_closure(entry, closure) + result = subprocess.run([binary, 'run', '--source-root', str(closure), + '--entry', str(staged_entry), + '--claim-run', '--arg', 'url=' + url], capture_output=True, text=True, timeout=120) + if result.returncode: + raise RuntimeError(result.stdout + result.stderr) + assert observed.get('query') == {'ApiKey': [fixture], 'Command': ['namecheap.domains.dns.getHosts']}, observed + assert observed.get('curl_seen') and not observed['argv_exposed'], observed + print(json.dumps({'get_query_transport': 'passed', 'query_received': True, 'key_in_argv': False})) +finally: + server.shutdown() + server.server_close() + scratch.cleanup() diff --git a/test/namecheap/support.py b/test/namecheap/support.py new file mode 100644 index 00000000000..06278b53bbe --- /dev/null +++ b/test/namecheap/support.py @@ -0,0 +1,30 @@ +"""Test-only import closure staging shared by offline Namecheap controls.""" +import pathlib +import re +import shutil + + +def stage_import_closure(entry, destination): + root = pathlib.Path.cwd() + index = {} + for tree in ('dag', 'src/v2'): + for source in (root / tree).rglob('*.dag'): + content = source.read_text() + module = re.search(r'^module\s+([\w.]+)', content, re.M) + if module: + index[module[1]] = (source, content) + pending, seen = [entry], set() + while pending: + source = pending.pop() + if source in seen: + continue + seen.add(source) + for module in re.findall(r'^import\s+([\w.]+)', source.read_text(), re.M): + if module not in index: + raise RuntimeError('Unresolved fixture import: ' + module) + pending.append(index[module][0]) + for source in seen: + copied = destination / source.relative_to(root) + copied.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, copied) + return destination / entry.relative_to(root) diff --git a/test/namecheap/workflow_success.py b/test/namecheap/workflow_success.py new file mode 100644 index 00000000000..d0b19b3e731 --- /dev/null +++ b/test/namecheap/workflow_success.py @@ -0,0 +1,40 @@ +"""Run the emitted observer shell with a successful observer stand-in, offline.""" +import os +import pathlib +import re +import subprocess +import tempfile + +import yaml + +root = pathlib.Path.cwd() +workflow = yaml.safe_load((root / '.github/workflows/fleet-converge.yml').read_text()) +job = workflow['jobs']['namecheap-observe'] +script = next(s['run'] for s in job['steps'] if s.get('id') == 'namecheap_observe') +upload = next(s for s in job['steps'] if s.get('id') == 'namecheap_secret_receipt') +# The stand-in produces only the artifact declared by the observer, independently +# of the generated console consumer and uploader under test. +authority = (root / 'dag/gunbc/namecheap/observation_artifact.dag').read_text() +receipt = re.search(r'data namecheap_observation_receipt_path: String = "([^"]+)"', authority)[1] +assert upload['with']['path'] == receipt +assert upload['if'] == 'success()' +with tempfile.TemporaryDirectory(prefix='namecheap-success-') as temp: + sandbox = pathlib.Path(temp) + binary = sandbox / 'target/release/gunbc' + binary.parent.mkdir(parents=True) + binary.write_text('#!/bin/sh\nset -eu\nprintf \'{"fixture":"successful-observation"}\\n\' > "$DECLARED_RECEIPT"\n') + binary.chmod(0o700) + env = dict(os.environ, DECLARED_RECEIPT=receipt) + # Avoid inheriting the invoking checkout through Git environment overrides. + env = {k: v for k, v in env.items() if not k.startswith('GIT_')} + run = subprocess.run(['bash', '--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', script], + cwd=sandbox, env=env, capture_output=True, text=True, timeout=15) + assert run.returncode == 0, run.stderr + assert (sandbox / upload['with']['path']).read_text() == '{"fixture":"successful-observation"}\n' + assert sorted(str(p.relative_to(sandbox)) for p in sandbox.rglob('*') if p.is_file()) == sorted([receipt, 'target/release/gunbc']) + # Planted original defect must fail even after the legitimate receipt exists. + broken = script + '\ncat "$ROOT/target/unproduced-execution.txt"\n' + bad = subprocess.run(['bash', '--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', broken], + cwd=sandbox, env=env, capture_output=True, text=True, timeout=15) + assert bad.returncode != 0 +print('Emitted success path passed; exact upload artifact exists; unmatched receipt read fails')