From 85007db90c429a82fc95e9deb3f2e0ba8b4fc6df Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 2 Jul 2026 15:37:24 +0800 Subject: [PATCH] fix(reboot): add windows99 no-secret verify collector --- .../reboot_auto_recovery_slo_scorecard.py | 7 + ..._reboot_auto_recovery_slo_scorecard_api.py | 10 + docs/LOGBOOK.md | 16 + .../collect-windows99-vmware-verify.sh | 273 ++++++++++++++++++ .../reboot-auto-recovery-slo-scorecard.py | 7 + ...test_reboot_auto_recovery_slo_scorecard.py | 9 + .../test_windows99_vmware_verify_collector.py | 217 ++++++++++++++ 7 files changed, 539 insertions(+) create mode 100755 scripts/reboot-recovery/collect-windows99-vmware-verify.sh create mode 100644 scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py diff --git a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py index 03df015f..2a0cb87f 100644 --- a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py +++ b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py @@ -503,6 +503,12 @@ def _build_windows99_verify_collection_packet( "powershell -ExecutionPolicy Bypass -File " ".\\windows99-vmware-autostart.ps1 -Mode Verify" ), + "no_secret_collector_check_command": ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --check" + ), + "no_secret_collector_collect_command": ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect" + ), "post_verifier": ( "rerun_reboot_auto_recovery_slo_scorecard_with_" "windows99_vmware_file_no_secret_no_reboot" @@ -511,6 +517,7 @@ def _build_windows99_verify_collection_packet( "safe_collection_channels": [ "authorized_windows99_console_verify_stdout_only", "existing_management_channel_verify_mode_only", + "no_secret_ssh_batchmode_verify_collector", "committed_no_secret_artifact_file_then_scorecard_rerun", ], "forbidden_actions": [ diff --git a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py index 43227b57..3f9d5b7a 100644 --- a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py +++ b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py @@ -262,6 +262,16 @@ def _assert_reboot_slo_payload(payload: dict): ] assert "VMRUN_PRESENT" in collection["expected_no_secret_output_fields"] assert "-Mode Verify" in collection["no_secret_verify_command"] + assert collection["no_secret_collector_check_command"] == ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --check" + ) + assert collection["no_secret_collector_collect_command"] == ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect" + ) + assert ( + "no_secret_ssh_batchmode_verify_collector" + in collection["safe_collection_channels"] + ) assert "host_reboot" in collection["forbidden_actions"] assert "windows_password_or_secret_collection" in collection["forbidden_actions"] windows99_management = payload["windows99_management_channel"] diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index fcbf87cb..3b8d442d 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -52907,3 +52907,19 @@ production browser smoke: **下一步**: - amend / push 到 Gitea main 後讀回新的 CD;deploy 後驗證 reboot scorecard 與 priority work-order production API 已出現 `windows99_management_channel` 與 `windows99_remote_execution_channel_ready=false`。 + +## 2026-07-02 — Windows99 VMware no-secret verify collector + +**完成內容**: +- 新增 `scripts/reboot-recovery/collect-windows99-vmware-verify.sh`,把 Windows99 VMware verify collection 從 API 指示推進為可執行 collector;預設 `--check` 只做 bounded port probe 與 BatchMode public-key SSH auth probe,`--collect` 只在 auth ready 時執行 `-Mode Verify`,不讀密碼、不讀 secret、不做遠端寫入、不重啟、不改 VM 電源、不套 Windows policy。 +- `reboot-auto-recovery-slo-scorecard` CLI 與 API readback 補上 `no_secret_collector_check_command` / `no_secret_collector_collect_command` 與 `no_secret_ssh_batchmode_verify_collector` safe channel。 + +**本地驗證結果**: +- `DATABASE_URL=sqlite+aiosqlite:////tmp/awoooi-codex-api-test.db PYTHONPATH=apps/api python3.11 -m pytest -q scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py ops/runner/test_cd_controlled_runtime_profile.py`:`64 passed`。 +- `bash -n scripts/reboot-recovery/collect-windows99-vmware-verify.sh`、`python3 -m py_compile apps/api/src/services/reboot_auto_recovery_slo_scorecard.py scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py scripts/reboot-recovery/windows99-management-channel-probe.py`、`python3 ops/runner/guard-gitea-runner-pressure.py --root .`、`git diff --check`:通過。 +- live `--check` 對 `192.168.0.99`:`port_22_open=1`、`port_3389_open=1`、`port_5985_open=0`、`port_5986_open=0`、`port_9182_open=0`、`ssh_batchmode_auth_ready=0`、`remote_verify_attempted=0`、`verify_collection_status=blocked_ssh_publickey_auth_missing`、耗時約 `12.51s`。 + +**仍維持**: +- 沒有讀 secret / token / `.env` / raw sessions / SQLite / auth;沒有要求或收集 Windows 密碼。 +- 沒有使用 GitHub / gh / GitHub API / GitHub Actions。 +- 沒有重啟主機,沒有 service restart,沒有 VM power change,沒有 Windows policy apply,沒有 workflow_dispatch,沒有 DROP / TRUNCATE / restore / prune。 diff --git a/scripts/reboot-recovery/collect-windows99-vmware-verify.sh b/scripts/reboot-recovery/collect-windows99-vmware-verify.sh new file mode 100755 index 00000000..def418fc --- /dev/null +++ b/scripts/reboot-recovery/collect-windows99-vmware-verify.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +set -u + +MODE="check" +TARGET_HOST="${WINDOWS99_HOST:-192.168.0.99}" +CONNECT_TIMEOUT="${WINDOWS99_CONNECT_TIMEOUT:-3}" +SSH_TIMEOUT="${WINDOWS99_SSH_TIMEOUT:-3}" +SSH_PORT="${WINDOWS99_SSH_PORT:-22}" +MAX_AUTH_USERS="${WINDOWS99_MAX_AUTH_USERS:-2}" +KNOWN_HOSTS_FILE="${WINDOWS99_KNOWN_HOSTS_FILE:-/tmp/awoooi-windows99-known_hosts}" +REMOTE_VERIFY_COMMAND="${WINDOWS99_REMOTE_VERIFY_COMMAND:-powershell -NoProfile -ExecutionPolicy Bypass -File .\\windows99-vmware-autostart.ps1 -Mode Verify}" +SSH_USERS=(ogt wooo ooo administrator Administrator) + +if [[ -n "${WINDOWS99_SSH_USERS:-}" ]]; then + # shellcheck disable=SC2206 + SSH_USERS=(${WINDOWS99_SSH_USERS}) +fi + +is_positive_int() { + [[ "$1" =~ ^[1-9][0-9]*$ ]] +} + +if ! is_positive_int "${CONNECT_TIMEOUT}"; then + CONNECT_TIMEOUT=3 +fi +if ! is_positive_int "${SSH_TIMEOUT}"; then + SSH_TIMEOUT=3 +fi +if ! is_positive_int "${MAX_AUTH_USERS}"; then + MAX_AUTH_USERS=2 +fi + +usage() { + printf '%s\n' "usage: $0 [--check|--collect] [--host HOST] [--users 'u1 u2'] [--timeout SECONDS]" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) + MODE="check" + ;; + --collect) + MODE="collect" + ;; + --host) + shift + TARGET_HOST="${1:-}" + ;; + --users) + shift + # shellcheck disable=SC2206 + SSH_USERS=(${1:-}) + ;; + --timeout) + shift + CONNECT_TIMEOUT="${1:-5}" + SSH_TIMEOUT="${CONNECT_TIMEOUT}" + ;; + --help|-h) + usage + exit 0 + ;; + *) + printf '%s\n' "error=unknown_argument:$1" >&2 + usage >&2 + exit 64 + ;; + esac + shift +done + +if ! is_positive_int "${CONNECT_TIMEOUT}"; then + CONNECT_TIMEOUT=3 +fi +if ! is_positive_int "${SSH_TIMEOUT}"; then + SSH_TIMEOUT=3 +fi +if ! is_positive_int "${MAX_AUTH_USERS}"; then + MAX_AUTH_USERS=2 +fi + +if [[ "${MODE}" != "check" && "${MODE}" != "collect" ]]; then + printf '%s\n' "error=invalid_mode:${MODE}" >&2 + exit 64 +fi + +PORT_TIMEOUT_WRAPPER="none" +if command -v timeout >/dev/null 2>&1; then + PORT_TIMEOUT_WRAPPER="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + PORT_TIMEOUT_WRAPPER="gtimeout" +fi + +port_open() { + local port="$1" + if ! command -v nc >/dev/null 2>&1; then + return 1 + fi + if [[ "${PORT_TIMEOUT_WRAPPER}" == "timeout" ]]; then + timeout "$((CONNECT_TIMEOUT + 1))s" nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + elif [[ "${PORT_TIMEOUT_WRAPPER}" == "gtimeout" ]]; then + gtimeout "$((CONNECT_TIMEOUT + 1))s" nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + else + nc -z -w "${CONNECT_TIMEOUT}" "${TARGET_HOST}" "${port}" >/dev/null 2>&1 + fi +} + +bool_for_port() { + local port="$1" + if port_open "${port}"; then + printf '1' + else + printf '0' + fi +} + +join_users() { + local joined="" + local user + for user in "${SSH_USERS[@]}"; do + if [[ -z "${joined}" ]]; then + joined="${user}" + else + joined="${joined},${user}" + fi + done + printf '%s' "${joined}" +} + +PORT_22_OPEN="$(bool_for_port 22)" +PORT_3389_OPEN="$(bool_for_port 3389)" +PORT_5985_OPEN="$(bool_for_port 5985)" +PORT_5986_OPEN="$(bool_for_port 5986)" +PORT_9182_OPEN="$(bool_for_port 9182)" + +SSH_BATCHMODE_AUTH_READY=0 +SSH_AUTHENTICATED_USER="" +SSH_AUTH_PROBE_EXIT_STATUS="not_attempted" +SSH_AUTH_PROBE_STDOUT_PRESENT=0 +SSH_AUTH_ATTEMPTED_USERS="$(join_users)" +SSH_AUTH_PROBED_USERS=0 +SSH_TIMEOUT_WRAPPER="none" +if command -v timeout >/dev/null 2>&1; then + SSH_TIMEOUT_WRAPPER="timeout" +elif command -v gtimeout >/dev/null 2>&1; then + SSH_TIMEOUT_WRAPPER="gtimeout" +fi + +SSH_OPTS=( + -o BatchMode=yes + -o PreferredAuthentications=publickey + -o PubkeyAuthentication=yes + -o PasswordAuthentication=no + -o KbdInteractiveAuthentication=no + -o NumberOfPasswordPrompts=0 + -o ConnectTimeout="${SSH_TIMEOUT}" + -o ConnectionAttempts=1 + -o GSSAPIAuthentication=no + -o LogLevel=ERROR + -o StrictHostKeyChecking=no + -o UserKnownHostsFile="${KNOWN_HOSTS_FILE}" + -p "${SSH_PORT}" +) + +run_ssh() { + local user="$1" + shift + if [[ "${SSH_TIMEOUT_WRAPPER}" == "timeout" ]]; then + timeout "$((SSH_TIMEOUT + 1))s" ssh "${SSH_OPTS[@]}" "${user}@${TARGET_HOST}" "$@" + elif [[ "${SSH_TIMEOUT_WRAPPER}" == "gtimeout" ]]; then + gtimeout "$((SSH_TIMEOUT + 1))s" ssh "${SSH_OPTS[@]}" "${user}@${TARGET_HOST}" "$@" + else + ssh "${SSH_OPTS[@]}" "${user}@${TARGET_HOST}" "$@" + fi +} + +if [[ "${PORT_22_OPEN}" == "1" ]]; then + for user in "${SSH_USERS[@]}"; do + if [[ "${SSH_AUTH_PROBED_USERS}" -ge "${MAX_AUTH_USERS}" ]]; then + break + fi + SSH_AUTH_PROBED_USERS=$((SSH_AUTH_PROBED_USERS + 1)) + auth_output="" + if auth_output="$(run_ssh "${user}" "echo AWOOOI_WINDOWS99_SSH_READY" 2>&1)"; then + SSH_BATCHMODE_AUTH_READY=1 + SSH_AUTHENTICATED_USER="${user}" + SSH_AUTH_PROBE_EXIT_STATUS=0 + if [[ -n "${auth_output}" ]]; then + SSH_AUTH_PROBE_STDOUT_PRESENT=1 + fi + break + else + SSH_AUTH_PROBE_EXIT_STATUS=$? + fi + done +fi + +DRY_RUN="true" +REMOTE_VERIFY_ATTEMPTED=0 +REMOTE_VERIFY_EXIT_STATUS="not_attempted" +VERIFY_COLLECTION_STATUS="blocked_ssh_publickey_auth_missing" +SAFE_NEXT_STEP="select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_then_rerun_collector_no_password" +REMOTE_VERIFY_OUTPUT="" +PROCESS_EXIT_STATUS=0 + +if [[ "${PORT_22_OPEN}" != "1" ]]; then + VERIFY_COLLECTION_STATUS="blocked_ssh_port_closed" + SAFE_NEXT_STEP="enable_existing_ssh_management_channel_publickey_only_then_rerun_collector_no_secret" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi +elif [[ "${SSH_BATCHMODE_AUTH_READY}" != "1" ]]; then + VERIFY_COLLECTION_STATUS="blocked_ssh_publickey_auth_missing" + SAFE_NEXT_STEP="select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_then_rerun_collector_no_password" + if [[ "${MODE}" == "collect" ]]; then + PROCESS_EXIT_STATUS=75 + fi +elif [[ "${MODE}" == "check" ]]; then + VERIFY_COLLECTION_STATUS="ready_ssh_batchmode_auth_probe_only" + SAFE_NEXT_STEP="rerun_collector_with_collect_then_commit_no_secret_verify_artifact_and_scorecard_rerun" +else + DRY_RUN="false" + REMOTE_VERIFY_ATTEMPTED=1 + if REMOTE_VERIFY_OUTPUT="$(run_ssh "${SSH_AUTHENTICATED_USER}" "${REMOTE_VERIFY_COMMAND}" 2>&1)"; then + REMOTE_VERIFY_EXIT_STATUS=0 + VERIFY_COLLECTION_STATUS="collected_windows99_vmware_verify_stdout" + SAFE_NEXT_STEP="commit_no_secret_verify_artifact_then_rerun_reboot_auto_recovery_slo_scorecard" + else + REMOTE_VERIFY_EXIT_STATUS=$? + VERIFY_COLLECTION_STATUS="blocked_remote_verify_command_failed" + SAFE_NEXT_STEP="inspect_no_secret_verify_stdout_then_fix_verify_script_or_path_and_rerun_collector" + PROCESS_EXIT_STATUS=75 + fi +fi + +printf '%s\n' "schema_version=windows99_vmware_verify_collector_v1" +printf '%s\n' "dry_run=${DRY_RUN}" +printf '%s\n' "target_host=${TARGET_HOST}" +printf '%s\n' "target_host_alias=99" +printf '%s\n' "connect_timeout_seconds=${CONNECT_TIMEOUT}" +printf '%s\n' "ssh_timeout_seconds=${SSH_TIMEOUT}" +printf '%s\n' "port_timeout_wrapper=${PORT_TIMEOUT_WRAPPER}" +printf '%s\n' "ssh_auth_probe_user_limit=${MAX_AUTH_USERS}" +printf '%s\n' "ssh_timeout_wrapper=${SSH_TIMEOUT_WRAPPER}" +printf '%s\n' "port_22_open=${PORT_22_OPEN}" +printf '%s\n' "port_3389_open=${PORT_3389_OPEN}" +printf '%s\n' "port_5985_open=${PORT_5985_OPEN}" +printf '%s\n' "port_5986_open=${PORT_5986_OPEN}" +printf '%s\n' "port_9182_open=${PORT_9182_OPEN}" +printf '%s\n' "ssh_candidate_users=${SSH_AUTH_ATTEMPTED_USERS}" +printf '%s\n' "ssh_auth_probed_users=${SSH_AUTH_PROBED_USERS}" +printf '%s\n' "ssh_batchmode_auth_ready=${SSH_BATCHMODE_AUTH_READY}" +printf '%s\n' "ssh_authenticated_user=${SSH_AUTHENTICATED_USER}" +printf '%s\n' "ssh_auth_probe_exit_status=${SSH_AUTH_PROBE_EXIT_STATUS}" +printf '%s\n' "ssh_auth_probe_stdout_present=${SSH_AUTH_PROBE_STDOUT_PRESENT}" +printf '%s\n' "remote_verify_attempted=${REMOTE_VERIFY_ATTEMPTED}" +printf '%s\n' "remote_verify_exit_status=${REMOTE_VERIFY_EXIT_STATUS}" +printf '%s\n' "verify_collection_status=${VERIFY_COLLECTION_STATUS}" +printf '%s\n' "safe_next_step=${SAFE_NEXT_STEP}" +printf '%s\n' "secret_value_read=false" +printf '%s\n' "password_prompt_allowed=false" +printf '%s\n' "remote_write_performed=false" +printf '%s\n' "host_reboot_performed=false" +printf '%s\n' "vm_power_change_performed=false" +printf '%s\n' "windows_update_policy_apply_performed=false" + +if [[ "${REMOTE_VERIFY_ATTEMPTED}" == "1" ]]; then + printf '%s\n' "remote_verify_output_begin" + printf '%s\n' "${REMOTE_VERIFY_OUTPUT}" + printf '%s\n' "remote_verify_output_end" +fi + +exit "${PROCESS_EXIT_STATUS}" diff --git a/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py b/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py index 896cc640..7b8613bf 100755 --- a/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py +++ b/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py @@ -842,6 +842,12 @@ def build_windows99_verify_collection_packet( "powershell -ExecutionPolicy Bypass -File " ".\\windows99-vmware-autostart.ps1 -Mode Verify" ), + "no_secret_collector_check_command": ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --check" + ), + "no_secret_collector_collect_command": ( + "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect" + ), "post_verifier": ( "rerun_reboot_auto_recovery_slo_scorecard_with_" "windows99_vmware_file_no_secret_no_reboot" @@ -850,6 +856,7 @@ def build_windows99_verify_collection_packet( "safe_collection_channels": [ "authorized_windows99_console_verify_stdout_only", "existing_management_channel_verify_mode_only", + "no_secret_ssh_batchmode_verify_collector", "committed_no_secret_artifact_file_then_scorecard_rerun", ], "forbidden_actions": [ diff --git a/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py b/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py index b0b9f2e9..0417925a 100644 --- a/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py +++ b/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py @@ -368,6 +368,15 @@ def test_missing_windows99_vmware_readback_fails_closed(tmp_path: Path) -> None: assert "-Mode Verify" in payload["windows99_verify_collection"][ "no_secret_verify_command" ] + assert payload["windows99_verify_collection"][ + "no_secret_collector_check_command" + ] == "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --check" + assert payload["windows99_verify_collection"][ + "no_secret_collector_collect_command" + ] == "bash scripts/reboot-recovery/collect-windows99-vmware-verify.sh --collect" + assert "no_secret_ssh_batchmode_verify_collector" in payload[ + "windows99_verify_collection" + ]["safe_collection_channels"] assert "windows_password_or_secret_collection" in payload[ "windows99_verify_collection" ]["forbidden_actions"] diff --git a/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py b/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py new file mode 100644 index 00000000..96e63371 --- /dev/null +++ b/scripts/reboot-recovery/tests/test_windows99_vmware_verify_collector.py @@ -0,0 +1,217 @@ +from __future__ import annotations + +import os +import subprocess +import textwrap +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "reboot-recovery" / "collect-windows99-vmware-verify.sh" + + +def _write_executable(path: Path, text: str) -> None: + path.write_text(textwrap.dedent(text).lstrip()) + path.chmod(0o755) + + +def _run_collector(fake_bin: Path, *args: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env["PATH"] = f"{fake_bin}:{env['PATH']}" + env["WINDOWS99_KNOWN_HOSTS_FILE"] = str(fake_bin / "known_hosts") + return subprocess.run( + ["bash", str(SCRIPT), *args], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def _key_values(stdout: str) -> dict[str, str]: + values: dict[str, str] = {} + for line in stdout.splitlines(): + if "=" not in line: + continue + key, value = line.split("=", 1) + values[key] = value + return values + + +def test_collector_contract_forbids_secret_and_runtime_actions() -> None: + text = SCRIPT.read_text() + + assert "BatchMode=yes" in text + assert "PreferredAuthentications=publickey" in text + assert "PubkeyAuthentication=yes" in text + assert "PasswordAuthentication=no" in text + assert "KbdInteractiveAuthentication=no" in text + assert "NumberOfPasswordPrompts=0" in text + assert "ConnectionAttempts=1" in text + assert "GSSAPIAuthentication=no" in text + for forbidden in [ + "sshpass", + "PasswordAuthentication=yes", + "KbdInteractiveAuthentication=yes", + "net use", + "shutdown", + "Restart-Computer", + "Start-VM", + "vmrun start", + "Set-ItemProperty", + "Register-ScheduledTask", + ]: + assert forbidden not in text + + +def test_check_mode_reports_open_ports_and_missing_publickey_auth(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + port="${!#}" + if [[ "$port" == "22" || "$port" == "3389" ]]; then + exit 0 + fi + exit 1 + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + printf '%s\n' 'Permission denied (publickey,password,keyboard-interactive).' >&2 + exit 255 + """, + ) + + result = _run_collector(fake_bin, "--check") + + assert result.returncode == 0 + values = _key_values(result.stdout) + assert values["schema_version"] == "windows99_vmware_verify_collector_v1" + assert values["dry_run"] == "true" + assert values["ssh_auth_probe_user_limit"] == "2" + assert values["port_22_open"] == "1" + assert values["port_3389_open"] == "1" + assert values["port_5985_open"] == "0" + assert values["port_5986_open"] == "0" + assert values["ssh_batchmode_auth_ready"] == "0" + assert values["ssh_auth_probed_users"] == "2" + assert values["remote_verify_attempted"] == "0" + assert values["verify_collection_status"] == "blocked_ssh_publickey_auth_missing" + assert values["secret_value_read"] == "false" + assert values["password_prompt_allowed"] == "false" + assert values["remote_write_performed"] == "false" + assert values["host_reboot_performed"] == "false" + assert values["vm_power_change_performed"] == "false" + assert values["windows_update_policy_apply_performed"] == "false" + + +def test_collect_mode_blocks_without_publickey_auth(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + port="${!#}" + [[ "$port" == "22" ]] + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + exit 255 + """, + ) + + result = _run_collector(fake_bin, "--collect") + + assert result.returncode == 75 + values = _key_values(result.stdout) + assert values["dry_run"] == "true" + assert values["ssh_batchmode_auth_ready"] == "0" + assert values["remote_verify_attempted"] == "0" + assert values["verify_collection_status"] == "blocked_ssh_publickey_auth_missing" + + +def test_check_mode_auth_ready_does_not_run_remote_verify(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + exit 0 + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + args="$*" + if [[ "$args" == *"powershell"* ]]; then + printf '%s\n' 'unexpected remote verify' >&2 + exit 44 + fi + printf '%s\n' 'AWOOOI_WINDOWS99_SSH_READY' + exit 0 + """, + ) + + result = _run_collector(fake_bin, "--check") + + assert result.returncode == 0 + values = _key_values(result.stdout) + assert values["ssh_batchmode_auth_ready"] == "1" + assert values["remote_verify_attempted"] == "0" + assert values["verify_collection_status"] == "ready_ssh_batchmode_auth_probe_only" + assert "VMRUN_PRESENT=1" not in result.stdout + assert "remote_verify_output_begin" not in result.stdout + + +def test_collect_mode_runs_readonly_remote_verify_when_auth_ready(tmp_path: Path) -> None: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + _write_executable( + fake_bin / "nc", + """ + #!/usr/bin/env bash + exit 0 + """, + ) + _write_executable( + fake_bin / "ssh", + """ + #!/usr/bin/env bash + args="$*" + if [[ "$args" == *"powershell"* ]]; then + printf '%s\n' 'AWOOOI_WINDOWS99_VMWARE_AUTOSTART=1' + printf '%s\n' 'MODE=Verify' + printf '%s\n' 'VMRUN_PRESENT=1' + printf '%s\n' 'VMWARE_AUTOSTART_VERIFY_READY=1' + exit 0 + fi + printf '%s\n' 'AWOOOI_WINDOWS99_SSH_READY' + exit 0 + """, + ) + + result = _run_collector(fake_bin, "--collect") + + assert result.returncode == 0 + values = _key_values(result.stdout) + assert values["dry_run"] == "false" + assert values["ssh_batchmode_auth_ready"] == "1" + assert values["remote_verify_attempted"] == "1" + assert values["remote_verify_exit_status"] == "0" + assert values["verify_collection_status"] == "collected_windows99_vmware_verify_stdout" + assert "remote_verify_output_begin" in result.stdout + assert "VMRUN_PRESENT=1" in result.stdout + assert "VMWARE_AUTOSTART_VERIFY_READY=1" in result.stdout + assert "remote_verify_output_end" in result.stdout