Files
awoooi/scripts/reboot-recovery/windows99-management-channel-probe.py
Your Name e28ebd5b3e
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m13s
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
fix(reboot): surface windows99 console artifact blockers
2026-07-03 09:25:20 +08:00

326 lines
11 KiB
Python

#!/usr/bin/env python3
"""No-secret Windows 99 management-channel readback.
This probe only checks whether a command channel is available for collecting the
Windows 99 VMware verifier. It does not read credentials, start VMs, or change
Windows/VMware state.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import socket
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any
SCHEMA_VERSION = "windows99_management_channel_readback_v1"
DEFAULT_PORTS = (22, 135, 445, 2179, 3389, 5985, 5986)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Probe no-secret management channels for Windows host 99.",
)
parser.add_argument("--host", default="192.168.0.99")
parser.add_argument(
"--ssh-user",
action="append",
dest="ssh_users",
help="SSH user to probe with BatchMode public-key auth. May be repeated.",
)
parser.add_argument("--tcp-timeout", type=float, default=2.0)
parser.add_argument("--ssh-timeout", type=int, default=8)
parser.add_argument(
"--port",
action="append",
type=int,
dest="ports",
help="TCP port to probe. May be passed more than once.",
)
parser.add_argument("--skip-ssh", action="store_true")
parser.add_argument(
"--console-artifact-status",
default="not_attempted",
choices=[
"not_attempted",
"blocked_clipboard_unreliable",
"blocked_focus_unreliable",
"blocked_truncated_output",
"collected_stdout",
"validated_artifact",
],
help=(
"Optional no-secret console artifact collection status. Use a blocked "
"value only after an attempted console stdout capture path fails."
),
)
parser.add_argument(
"--console-artifact-blocker",
action="append",
dest="console_artifact_blockers",
help="Optional machine-readable console artifact blocker. May be repeated.",
)
parser.add_argument("--generated-at", help="Override generated_at.")
parser.add_argument("--output", type=Path, help="Write JSON to this path.")
args = parser.parse_args()
if not args.ssh_users:
env_users = os.environ.get("WINDOWS99_SSH_USERS", "").split()
args.ssh_users = env_users or ["administrator"]
args.ssh_user = args.ssh_users[0]
return args
def tcp_status(host: str, port: int, timeout: float) -> str:
try:
with socket.create_connection((host, port), timeout=timeout):
return "open"
except TimeoutError:
return "timeout"
except ConnectionRefusedError:
return "refused"
except OSError as exc:
name = exc.__class__.__name__
if getattr(exc, "errno", None) is not None:
return f"{name}_{exc.errno}"
return name
def ping_status(host: str) -> dict[str, Any]:
if not shutil.which("ping"):
return {"checked": False, "ok": False, "status": "ping_missing"}
timeout_arg = "1000" if sys.platform == "darwin" else "1"
command = ["ping", "-c", "2", "-W", timeout_arg, host]
try:
result = subprocess.run(
command,
text=True,
capture_output=True,
timeout=5,
check=False,
)
except subprocess.TimeoutExpired:
return {"checked": True, "ok": False, "status": "timeout"}
return {
"checked": True,
"ok": result.returncode == 0,
"status": "ok" if result.returncode == 0 else "failed",
}
def classify_ssh_failure(stderr: str, returncode: int) -> str:
lowered = stderr.lower()
if "permission denied" in lowered:
return "permission_denied"
if "connection timed out" in lowered or "operation timed out" in lowered:
return "timeout"
if "connection refused" in lowered:
return "refused"
if "no route to host" in lowered:
return "no_route"
if returncode == 124:
return "timeout"
return "failed"
def ssh_batch_status(host: str, user: str, timeout: int, port_open: bool) -> dict[str, Any]:
if not port_open:
return {"checked": False, "ready": False, "status": "port_not_open"}
ssh = shutil.which("ssh")
if not ssh:
return {"checked": False, "ready": False, "status": "ssh_missing"}
command = [
ssh,
"-o",
"BatchMode=yes",
"-o",
f"ConnectTimeout={timeout}",
"-o",
"PreferredAuthentications=publickey",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"GlobalKnownHostsFile=/dev/null",
f"{user}@{host}",
"cmd",
"/c",
"echo",
"AWOOOI_WINDOWS99_SSH_OK",
]
try:
result = subprocess.run(
command,
text=True,
capture_output=True,
timeout=timeout + 3,
check=False,
)
except subprocess.TimeoutExpired:
return {"checked": True, "ready": False, "status": "timeout"}
ready = (
result.returncode == 0
and "AWOOOI_WINDOWS99_SSH_OK" in (result.stdout or "")
)
return {
"checked": True,
"ready": ready,
"status": "ready"
if ready
else classify_ssh_failure(result.stderr or "", result.returncode),
}
def build_payload(args: argparse.Namespace) -> dict[str, Any]:
ports = tuple(args.ports or DEFAULT_PORTS)
ssh_users = list(
getattr(args, "ssh_users", None)
or [getattr(args, "ssh_user", "administrator")]
)
if not ssh_users:
ssh_users = ["administrator"]
generated_at = args.generated_at or datetime.now().astimezone().isoformat(timespec="seconds")
tcp_ports = {
str(port): tcp_status(args.host, port, args.tcp_timeout)
for port in ports
}
ping = ping_status(args.host)
host_reachable = ping["ok"] or any(status == "open" for status in tcp_ports.values())
winrm_http_open = tcp_ports.get("5985") == "open"
winrm_https_open = tcp_ports.get("5986") == "open"
hyperv_vmconnect_open = tcp_ports.get("2179") == "open"
rdp_console_reachable = tcp_ports.get("3389") == "open"
console_collection_channels = []
if rdp_console_reachable:
console_collection_channels.append("rdp_console")
if hyperv_vmconnect_open:
console_collection_channels.append("hyperv_vmconnect")
local_console_channel_reachable = bool(console_collection_channels)
ssh_batch_candidates: list[dict[str, Any]] = []
if args.skip_ssh:
ssh_probe = {"checked": False, "ready": False, "status": "skipped"}
else:
for user in ssh_users:
candidate = ssh_batch_status(
args.host,
user,
args.ssh_timeout,
tcp_ports.get("22") == "open",
)
candidate = {"user": user, **candidate}
ssh_batch_candidates.append(candidate)
if candidate["ready"] is True:
break
ready_candidate = next(
(candidate for candidate in ssh_batch_candidates if candidate["ready"] is True),
None,
)
ssh_probe = dict(ready_candidate or ssh_batch_candidates[0])
ssh_probe.pop("user", None)
remote_execution_ready = ssh_probe["ready"] is True
console_artifact_status = str(
getattr(args, "console_artifact_status", "not_attempted")
or "not_attempted"
)
console_artifact_blockers = list(
getattr(args, "console_artifact_blockers", None) or []
)
if console_artifact_status == "blocked_clipboard_unreliable":
console_artifact_blockers.append("windows99_console_clipboard_unreliable")
elif console_artifact_status == "blocked_focus_unreliable":
console_artifact_blockers.append("windows99_console_focus_unreliable")
elif console_artifact_status == "blocked_truncated_output":
console_artifact_blockers.append("windows99_console_verify_output_truncated")
console_artifact_blockers = list(dict.fromkeys(console_artifact_blockers))
console_artifact_reliable = console_artifact_status in {
"collected_stdout",
"validated_artifact",
}
blockers: list[str] = []
if not host_reachable:
blockers.append("windows99_host_unreachable_from_management_probe")
if not remote_execution_ready:
blockers.append("windows99_remote_execution_channel_unavailable")
if not (winrm_http_open or winrm_https_open):
blockers.append("windows99_winrm_unavailable")
if tcp_ports.get("22") == "open" and ssh_probe["status"] == "permission_denied":
blockers.append("windows99_ssh_batch_denied")
blockers.extend(console_artifact_blockers)
return {
"schema_version": SCHEMA_VERSION,
"readback_present": True,
"generated_at": generated_at,
"host": args.host,
"ping": ping,
"host_reachable": host_reachable,
"tcp_ports": tcp_ports,
"ssh_user": (
next(
(
candidate["user"]
for candidate in ssh_batch_candidates
if candidate.get("ready") is True
),
ssh_users[0],
)
if ssh_users
else ""
),
"ssh_users": ssh_users,
"ssh_batch": ssh_probe,
"ssh_batch_candidates": ssh_batch_candidates,
"winrm_http_open": winrm_http_open,
"winrm_https_open": winrm_https_open,
"hyperv_vmconnect_open": hyperv_vmconnect_open,
"rdp_console_reachable": rdp_console_reachable,
"local_console_channel_reachable": local_console_channel_reachable,
"console_collection_channels": console_collection_channels,
"console_artifact_status": console_artifact_status,
"console_artifact_reliable": console_artifact_reliable,
"console_artifact_blockers": console_artifact_blockers,
"console_artifact_safe_next_step": (
"validate_collected_console_stdout_then_rerun_reboot_scorecard"
if console_artifact_reliable
else (
"use_authorized_no_secret_management_channel_or_manual_console_stdout_capture"
if console_artifact_blockers
else "attempt_console_stdout_capture_only_if_focus_and_clipboard_are_reliable"
)
),
"remote_execution_channel_ready": remote_execution_ready,
"can_collect_vmware_verify_without_secret": remote_execution_ready,
"blockers": blockers,
"forbidden_actions": [
"read_windows_password",
"read_secret_value",
"start_vm",
"reboot_host",
"restart_service",
"write_windows_policy",
],
}
def main() -> int:
args = parse_args()
payload = build_payload(args)
output = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
if args.output:
args.output.write_text(output, encoding="utf-8")
else:
sys.stdout.write(output)
return 0
if __name__ == "__main__":
raise SystemExit(main())