diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 039297f..9aab98f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,8 @@ jobs: bash tests/test_monitor_ib_gateway_ready.sh bash tests/test_workflow_shared_config.sh bash tests/test_docker_compose_ports.sh + bash tests/test_read_only_vm_metadata_diagnostic.sh + python3 -m unittest discover -s tests -p 'test_match_gateway_metadata.py' docker-build: runs-on: ubuntu-latest diff --git a/.github/workflows/read-only-vm-metadata-diagnostic.yml b/.github/workflows/read-only-vm-metadata-diagnostic.yml index 64d0059..58c4d06 100644 --- a/.github/workflows/read-only-vm-metadata-diagnostic.yml +++ b/.github/workflows/read-only-vm-metadata-diagnostic.yml @@ -5,11 +5,16 @@ on: inputs: target: description: One gateway target from IB_GATEWAY_TARGETS_JSON - required: true + required: false type: string + match_current_gateway: + description: Compare the current private Gateway host against all four configured targets + required: true + default: false + type: boolean concurrency: - group: read-only-gateway-vm-metadata-${{ github.event.inputs.target }} + group: read-only-gateway-vm-metadata-${{ inputs.match_current_gateway && 'match-current' || inputs.target }} cancel-in-progress: false jobs: @@ -20,19 +25,52 @@ jobs: steps: - id: resolve name: Resolve one gateway target + env: + MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} + MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} + LEGACY_TARGETS_JSON: ${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }} + SELECTED_TARGET: ${{ inputs.target }} uses: actions/github-script@v8 with: script: | - const raw = ${{ toJSON(vars.IB_GATEWAY_TARGETS_JSON) }}; + const matchMode = process.env.MATCH_CURRENT_GATEWAY === "true"; + const raw = matchMode ? process.env.MATCH_TARGETS_JSON : process.env.LEGACY_TARGETS_JSON; if (!raw || !raw.trim()) { - core.setFailed("IB_GATEWAY_TARGETS_JSON is required"); + core.setFailed(matchMode ? "Protected gateway target inventory is required" : "IB_GATEWAY_TARGETS_JSON is required"); return; } - const loaded = JSON.parse(raw); - const targets = Array.isArray(loaded) - ? loaded - : Object.entries(loaded).map(([name, config]) => ({...config, name})); - const selectedName = ${{ toJSON(github.event.inputs.target) }}; + let loaded; + let targets; + try { + loaded = JSON.parse(raw); + targets = Array.isArray(loaded) + ? loaded + : Object.entries(loaded).map(([name, config]) => ({...config, name})); + } catch { + core.setFailed(matchMode ? "Protected gateway target inventory is invalid" : "IB_GATEWAY_TARGETS_JSON is invalid"); + return; + } + if (matchMode) { + const required = [ + "gcp_project_id", + "gcp_workload_identity_provider", + "gcp_workload_identity_service_account", + "gce_instance_name", + "gce_zone", + ]; + const names = new Set(); + if (targets.length !== 4 || targets.some((target) => { + if (!target || typeof target.name !== "string" || !target.name.trim() || names.has(target.name)) return true; + names.add(target.name); + return required.some((key) => typeof target[key] !== "string" || !target[key].trim()); + })) { + core.setFailed("Protected gateway target inventory is invalid"); + return; + } + core.setOutput("matrix", JSON.stringify({include: targets.map((_, target_index) => ({target_index}))})); + return; + } + const selectedName = process.env.SELECTED_TARGET; const targetIndex = targets.findIndex((target) => target && target.name === selectedName); if (targetIndex < 0) { core.setFailed("Unknown gateway target; choose one configured target"); @@ -50,6 +88,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 strategy: + fail-fast: true + max-parallel: 1 matrix: ${{ fromJson(needs.resolve.outputs.matrix) }} permissions: contents: read @@ -62,18 +102,63 @@ jobs: - id: metadata name: Resolve masked VM metadata + env: + MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} + MATCH_TARGETS_JSON: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }} + LEGACY_TARGETS_JSON: ${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }} + SELECTED_TARGET: ${{ inputs.target }} + TARGET_INDEX: ${{ matrix.target_index }} + TARGET_DIGEST: ${{ matrix.target_digest }} uses: actions/github-script@v8 with: script: | - const raw = ${{ toJSON(vars.IB_GATEWAY_TARGETS_JSON) }}; - const loaded = JSON.parse(raw); - const targets = Array.isArray(loaded) - ? loaded - : Object.entries(loaded).map(([name, config]) => ({...config, name})); - const target = targets[Number(${{ toJSON(matrix.target_index) }})]; - const crypto = require("crypto"); - if (!target || crypto.createHash("sha256").update(JSON.stringify(target)).digest("hex") !== ${{ toJSON(matrix.target_digest) }}) { - core.setFailed("Resolved gateway target changed between jobs"); + const matchMode = process.env.MATCH_CURRENT_GATEWAY === "true"; + const raw = matchMode ? process.env.MATCH_TARGETS_JSON : process.env.LEGACY_TARGETS_JSON; + if (!raw || !raw.trim()) { + core.setFailed(matchMode ? "Protected gateway target inventory is required" : "IB_GATEWAY_TARGETS_JSON is required"); + return; + } + let loaded; + let targets; + try { + loaded = JSON.parse(raw); + targets = Array.isArray(loaded) + ? loaded + : Object.entries(loaded).map(([name, config]) => ({...config, name})); + } catch { + core.setFailed(matchMode ? "Protected gateway target inventory is invalid" : "IB_GATEWAY_TARGETS_JSON is invalid"); + return; + } + const targetIndex = Number(process.env.TARGET_INDEX); + let target; + if (matchMode) { + const required = [ + "gcp_project_id", + "gcp_workload_identity_provider", + "gcp_workload_identity_service_account", + "gce_instance_name", + "gce_zone", + ]; + const names = new Set(); + if (targets.length !== 4 || targets.some((item) => { + if (!item || typeof item.name !== "string" || !item.name.trim() || names.has(item.name)) return true; + names.add(item.name); + return required.some((key) => typeof item[key] !== "string" || !item[key].trim()); + })) { + core.setFailed("Protected gateway target inventory is invalid"); + return; + } + target = targets[targetIndex]; + } else { + target = targets[targetIndex]; + const crypto = require("crypto"); + if (!target || crypto.createHash("sha256").update(JSON.stringify(target)).digest("hex") !== process.env.TARGET_DIGEST) { + core.setFailed("Resolved gateway target changed between jobs"); + return; + } + } + if (!target || !Number.isInteger(targetIndex) || targetIndex < 0 || targetIndex >= targets.length) { + core.setFailed("Resolved gateway target is invalid"); return; } const values = { @@ -95,6 +180,8 @@ jobs: core.exportVariable(name, String(value)); } } + core.exportVariable("MATCH_CURRENT_GATEWAY", matchMode ? "true" : "false"); + core.exportVariable("TARGET_INDEX", String(targetIndex)); - name: Authenticate to Google Cloud uses: google-github-actions/auth@v3 @@ -111,6 +198,9 @@ jobs: - name: Read VM metadata without connecting env: GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_project_id }} + MATCH_CURRENT_GATEWAY: ${{ inputs.match_current_gateway }} + TARGET_INDEX: ${{ matrix.target_index }} + IB_GATEWAY_EXPECTED_HOST: ${{ inputs.match_current_gateway && secrets.IB_GATEWAY_EXPECTED_HOST || '' }} run: | set -euo pipefail metadata_file="$(mktemp)" @@ -119,10 +209,22 @@ jobs: --project "${GCP_PROJECT_ID}" \ --zone "${GCE_ZONE}" \ --format=json >"${metadata_file}" 2>&1; then + if [ "${MATCH_CURRENT_GATEWAY}" = "true" ]; then + echo "GATEWAY_IP_MATCH_STATUS=unknown" + echo "GATEWAY_IP_MATCH_INDEX=${TARGET_INDEX}" + python3 scripts/classify_gcloud_metadata_failure.py --input "${metadata_file}" + exit 1 + fi echo "GATEWAY_VM_DIAGNOSTIC_STATUS=METADATA_UNAVAILABLE" python3 scripts/classify_gcloud_metadata_failure.py --input "${metadata_file}" exit 1 fi + if [ "${MATCH_CURRENT_GATEWAY}" = "true" ]; then + python3 scripts/match_gateway_metadata.py \ + --metadata-file "${metadata_file}" \ + --target-index "${TARGET_INDEX}" + exit $? + fi VM_METADATA_FILE="${metadata_file}" python3 - <<'PY' import json import os diff --git a/scripts/match_gateway_metadata.py b/scripts/match_gateway_metadata.py new file mode 100644 index 0000000..1444991 --- /dev/null +++ b/scripts/match_gateway_metadata.py @@ -0,0 +1,72 @@ +"""Compare one authorized VM metadata response with the protected Gateway host.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +from pathlib import Path +from typing import Any + + +_PRIVATE_V4_NETWORKS = tuple( + ipaddress.ip_network(value) + for value in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16") +) + + +def _private_ipv4(value: Any) -> str | None: + if not isinstance(value, str) or not value.strip(): + return None + try: + parsed = ipaddress.ip_address(value.strip()) + except ValueError: + return None + if parsed.version != 4 or not any(parsed in network for network in _PRIVATE_V4_NETWORKS): + return None + return str(parsed) + + +def compare_gateway_host(metadata: Any, expected_host: Any) -> str: + """Return match/no_match only for a complete, valid private-IPv4 payload.""" + expected = _private_ipv4(expected_host) + if expected is None or not isinstance(metadata, dict): + return "unknown" + interfaces = metadata.get("networkInterfaces") + if not isinstance(interfaces, list) or not interfaces: + return "unknown" + + addresses: list[str] = [] + for interface in interfaces: + if not isinstance(interface, dict): + return "unknown" + address = _private_ipv4(interface.get("networkIP")) + if address is None: + return "unknown" + addresses.append(address) + return "match" if expected in addresses else "no_match" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--metadata-file", required=True, type=Path) + parser.add_argument("--target-index", required=True, type=int) + args = parser.parse_args(argv) + + status = "unknown" + if 0 <= args.target_index < 4: + expected = os.environ.get("IB_GATEWAY_EXPECTED_HOST") + try: + metadata = json.loads(args.metadata_file.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError): + metadata = None + status = compare_gateway_host(metadata, expected) + + print(f"GATEWAY_IP_MATCH_STATUS={status}") + print(f"GATEWAY_IP_MATCH_INDEX={args.target_index if 0 <= args.target_index < 4 else 'unknown'}") + return 1 if status == "unknown" else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_match_gateway_metadata.py b/tests/test_match_gateway_metadata.py new file mode 100644 index 0000000..033b439 --- /dev/null +++ b/tests/test_match_gateway_metadata.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import importlib.util +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "scripts" / "match_gateway_metadata.py" +SPEC = importlib.util.spec_from_file_location("match_gateway_metadata", SCRIPT) +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def _metadata(*addresses: str) -> dict[str, object]: + return {"networkInterfaces": [{"networkIP": address} for address in addresses]} + + +class GatewayMetadataMatchTests(unittest.TestCase): + def test_actual_metadata_payload_validation(self): + cases = [ + (_metadata("10.1.2.3"), "10.1.2.3", "match"), + (_metadata("10.1.2.3", "192.168.4.5"), "192.168.4.5", "match"), + (_metadata("10.1.2.3"), "10.1.2.4", "no_match"), + ({}, "10.1.2.3", "unknown"), + ({"networkInterfaces": []}, "10.1.2.3", "unknown"), + ({"networkInterfaces": [None]}, "10.1.2.3", "unknown"), + ({"networkInterfaces": [{"networkIP": "not-an-ip"}]}, "10.1.2.3", "unknown"), + ({"networkInterfaces": [{"networkIP": "2001:db8::1"}]}, "10.1.2.3", "unknown"), + ({"networkInterfaces": [{"networkIP": "8.8.8.8"}]}, "10.1.2.3", "unknown"), + (_metadata("10.1.2.3"), "8.8.8.8", "unknown"), + (_metadata("10.1.2.3"), "2001:db8::1", "unknown"), + ] + for payload, expected, status in cases: + with self.subTest(payload=payload, expected=expected): + self.assertEqual(MODULE.compare_gateway_host(payload, expected), status) + + def test_multiple_targets_can_match_and_cannot_be_declared_unique_locally(self): + statuses = [ + MODULE.compare_gateway_host(_metadata("10.1.2.3"), "10.1.2.3"), + MODULE.compare_gateway_host(_metadata("10.1.2.3", "192.168.4.5"), "10.1.2.3"), + MODULE.compare_gateway_host(_metadata("10.1.2.4"), "10.1.2.3"), + MODULE.compare_gateway_host(_metadata("10.1.2.5"), "10.1.2.3"), + ] + self.assertEqual(statuses.count("match"), 2) + self.assertEqual(statuses.count("no_match"), 2) + + def test_cli_reads_expected_host_from_environment_and_emits_only_fixed_fields(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text(json.dumps(_metadata("10.34.56.78")), encoding="utf-8") + sentinel = "10.34.56.78" + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "2"], + env={**os.environ, "IB_GATEWAY_EXPECTED_HOST": sentinel}, + capture_output=True, + text=True, + check=True, + ) + + self.assertEqual( + completed.stdout.splitlines(), + ["GATEWAY_IP_MATCH_STATUS=match", "GATEWAY_IP_MATCH_INDEX=2"], + ) + self.assertNotIn(sentinel, completed.stdout + completed.stderr) + self.assertNotIn("networkInterfaces", completed.stdout + completed.stderr) + + def test_cli_missing_expected_host_fails_closed_with_only_unknown(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text(json.dumps(_metadata("10.34.56.78")), encoding="utf-8") + environment = dict(os.environ) + environment.pop("IB_GATEWAY_EXPECTED_HOST", None) + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "0"], + env=environment, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 1) + self.assertEqual( + completed.stdout.splitlines(), + ["GATEWAY_IP_MATCH_STATUS=unknown", "GATEWAY_IP_MATCH_INDEX=0"], + ) + + def test_cli_rejects_out_of_range_index_without_echoing_it(self): + from tempfile import TemporaryDirectory + + with TemporaryDirectory() as temp_dir: + metadata_file = Path(temp_dir) / "metadata.json" + metadata_file.write_text(json.dumps(_metadata("10.34.56.78")), encoding="utf-8") + completed = subprocess.run( + [sys.executable, str(SCRIPT), "--metadata-file", str(metadata_file), "--target-index", "999"], + env={**os.environ, "IB_GATEWAY_EXPECTED_HOST": "10.34.56.78"}, + capture_output=True, + text=True, + ) + + self.assertEqual(completed.returncode, 1) + self.assertEqual( + completed.stdout.splitlines(), + ["GATEWAY_IP_MATCH_STATUS=unknown", "GATEWAY_IP_MATCH_INDEX=unknown"], + ) + self.assertNotIn("999", completed.stdout + completed.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_read_only_vm_metadata_diagnostic.sh b/tests/test_read_only_vm_metadata_diagnostic.sh index bfda910..7edfaaa 100755 --- a/tests/test_read_only_vm_metadata_diagnostic.sh +++ b/tests/test_read_only_vm_metadata_diagnostic.sh @@ -7,6 +7,9 @@ workflow_file="$repo_dir/.github/workflows/read-only-vm-metadata-diagnostic.yml" test -f "$workflow_file" grep -Fq 'name: Read-Only Gateway VM Metadata Diagnostic' "$workflow_file" grep -Fq 'workflow_dispatch:' "$workflow_file" +grep -Fq 'match_current_gateway:' "$workflow_file" +grep -A3 -Fq 'match_current_gateway:' "$workflow_file" +grep -Fq 'default: false' "$workflow_file" ! grep -Fq 'schedule:' "$workflow_file" ! grep -Fq 'push:' "$workflow_file" grep -Fq 'One gateway target from IB_GATEWAY_TARGETS_JSON' "$workflow_file" @@ -17,7 +20,7 @@ grep -Fq 'gcloud compute instances describe' "$workflow_file" grep -Fq 'uses: actions/checkout@v6' "$workflow_file" grep -Fq 'persist-credentials: false' "$workflow_file" grep -Fq 'GATEWAY_VM_DIAGNOSTIC_STATUS=VM_RUNNING' "$workflow_file" -grep -Fq 'scripts/classify_gcloud_metadata_failure.py' "$workflow_file" +test "$(grep -Fc 'scripts/classify_gcloud_metadata_failure.py' "$workflow_file")" -eq 2 grep -Fq 'GATEWAY_VM_DIAGNOSTIC_FAILURE_CLASS=' "$repo_dir/scripts/classify_gcloud_metadata_failure.py" grep -Fq 'GATEWAY_VM_DIAGNOSTIC_LIMIT=NO_GATEWAY_OR_CONTAINER_HEALTH_ASSERTION' "$workflow_file" for forbidden in \ @@ -38,3 +41,11 @@ do done grep -Fq 'GCP_PROJECT_ID: ${{ steps.metadata.outputs.gcp_project_id }}' "$workflow_file" +grep -Fq "MATCH_TARGETS_JSON: \${{ inputs.match_current_gateway && secrets.IB_GATEWAY_TARGETS_JSON || '' }}" "$workflow_file" +grep -Fq "LEGACY_TARGETS_JSON: \${{ !inputs.match_current_gateway && vars.IB_GATEWAY_TARGETS_JSON || '' }}" "$workflow_file" +grep -Fq "IB_GATEWAY_EXPECTED_HOST: \${{ inputs.match_current_gateway && secrets.IB_GATEWAY_EXPECTED_HOST || '' }}" "$workflow_file" +grep -Fq 'fail-fast: true' "$workflow_file" +grep -Fq 'max-parallel: 1' "$workflow_file" +grep -Fq 'scripts/match_gateway_metadata.py' "$workflow_file" +grep -Fq 'targets.map((_, target_index) => ({target_index}))' "$workflow_file" +test "$(grep -Fc 'gcloud compute instances describe' "$workflow_file")" -eq 1