#!/usr/bin/env python3 """ IwoooS public gateway 變更前置 Gate 只讀清冊。 本工具只讀取已提交的 Nginx drift snapshot 與 DNS / TLS snapshot,整理 public gateway reload / route change 前必備的 owner、diff、nginx -t、 route smoke、rollback 與維護窗口欄位。它不 SSH、不讀 live 主機、不執行 nginx -t、不 reload、不做 DNS / TLS probe,也不收 secret value。 """ from __future__ import annotations import argparse import json import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) REQUIRED_PREFLIGHT_GATES = [ { "gate_id": "PG1", "label": "source-of-truth hash", "required_evidence": "repo_source_raw_and_normalized_sha256", "owner_acceptance_required": False, "repo_only_ready": True, }, { "gate_id": "PG2", "label": "affected route list", "required_evidence": "domain_route_upstream_tls_admin_websocket_acme_inventory", "owner_acceptance_required": False, "repo_only_ready": True, }, { "gate_id": "PG3", "label": "owner response", "required_evidence": "owner role/team, decision, reason, affected scope, redacted refs, followup owner", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG4", "label": "owner-provided live config", "required_evidence": "redacted live conf export or owner statement", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG5", "label": "rendered diff", "required_evidence": "source-to-live rendered diff with redaction", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG6", "label": "nginx -t evidence", "required_evidence": "syntax test output captured by approved maintainer", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG7", "label": "public route smoke", "required_evidence": "public route smoke plan and result for affected domains", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG8", "label": "admin route smoke", "required_evidence": "admin/auth route smoke when admin path is affected", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG9", "label": "WebSocket / API smoke", "required_evidence": "WebSocket or API smoke when upstream or upgrade header is affected", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG10", "label": "ACME / TLS owner check", "required_evidence": "certificate path, SAN/wildcard/shared-cert statement, ACME impact", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG11", "label": "maintenance window", "required_evidence": "approved window and notification scope", "owner_acceptance_required": True, "repo_only_ready": False, }, { "gate_id": "PG12", "label": "rollback owner and ref", "required_evidence": "rollback owner, rollback file/ref, post-rollback smoke", "owner_acceptance_required": True, "repo_only_ready": False, }, ] EXECUTION_BOUNDARIES = { "runtime_execution_authorized": False, "host_live_conf_read_authorized": False, "ssh_read_authorized": False, "ssh_write_authorized": False, "host_write_authorized": False, "nginx_test_authorized": False, "nginx_test_executed": False, "nginx_reload_authorized": False, "nginx_reload_executed": False, "public_gateway_reload_authorized": False, "public_route_change_authorized": False, "admin_route_change_authorized": False, "websocket_route_change_authorized": False, "acme_challenge_change_authorized": False, "dns_query_executed": False, "live_tls_probe_executed": False, "certbot_renew_authorized": False, "certbot_renew_executed": False, "route_smoke_authorized": False, "route_smoke_executed": False, "rollback_executed": False, "secret_value_collection_allowed": False, "action_buttons_allowed": False, } def git_short_sha(root: Path) -> str: try: result = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True, capture_output=True, text=True, ) return result.stdout.strip() except Exception: return "unknown" def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def unique_upstreams(domains: list[dict[str, Any]]) -> list[str]: return sorted({upstream for item in domains for upstream in item.get("upstreams", [])}) def build_config_rows(nginx_report: dict[str, Any]) -> list[dict[str, Any]]: rows: list[dict[str, Any]] = [] for config in nginx_report.get("configs", []): parsed = config.get("repo_source", {}).get("parsed", {}) rows.append( { "config_id": config["config_id"], "host": config["host"], "role": config["role"], "control_tier": config["control_tier"], "owner_gate": config["owner_gate"], "repo_source_path": config["repo_source_path"], "live_path": config["live_path"], "server_block_count": parsed.get("server_block_count", 0), "server_name_count": len(parsed.get("server_names", [])), "upstream_count": len(parsed.get("proxy_passes", [])), "tls_certificate_path_count": len(parsed.get("ssl_certificates", [])), "admin_route_count": len(parsed.get("admin_routes", [])), "acme_route_count": len(parsed.get("acme_routes", [])), "websocket_route_count": len(parsed.get("websocket_routes", [])), "repo_source_hash_ready": True, "owner_response_received": False, "owner_response_accepted": False, "live_conf_evidence_received": False, "rendered_diff_ready": False, "nginx_test_evidence_received": False, "route_smoke_evidence_received": False, "maintenance_window_accepted": False, "rollback_owner_accepted": False, "runtime_gate_open": False, } ) return rows def build_route_impacts(domains: list[dict[str, Any]]) -> list[dict[str, Any]]: impacts: list[dict[str, Any]] = [] for item in domains: impacts.append( { "domain": item["domain"], "hosts": item.get("hosts", []), "config_ids": item.get("config_ids", []), "control_tier": item.get("control_tier", "C3"), "upstream_count": len(item.get("upstreams", [])), "has_tls_certificate_path": item.get("tls_certificate_path_present", False), "certificate_owner_confirmation_required": item.get( "certificate_owner_confirmation_required", False, ), "acme_challenge_present": item.get("acme_challenge_present", False), "admin_route_count": item.get("admin_route_count", 0), "websocket_route_count": item.get("websocket_route_count", 0), "public_route_smoke_required": True, "admin_route_smoke_required": item.get("admin_route_count", 0) > 0, "websocket_or_api_smoke_required": item.get("websocket_route_count", 0) > 0 or len(item.get("upstreams", [])) > 0, "tls_owner_check_required": item.get("certificate_owner_confirmation_required", False), "owner_response_accepted": False, "route_smoke_accepted": False, } ) return impacts def build_report( root: Path, nginx_report: dict[str, Any], domain_tls_report: dict[str, Any], generated_at: str | None, ) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") managed_domains = domain_tls_report.get("managed_domains", []) config_rows = build_config_rows(nginx_report) route_impacts = build_route_impacts(managed_domains) upstreams = unique_upstreams(managed_domains) repo_only_ready_count = sum(1 for gate in REQUIRED_PREFLIGHT_GATES if gate["repo_only_ready"]) owner_required_count = sum(1 for gate in REQUIRED_PREFLIGHT_GATES if gate["owner_acceptance_required"]) summary = { "source_config_count": len(config_rows), "c0_source_config_count": sum(1 for item in config_rows if item["control_tier"] == "C0"), "managed_domain_count": len(managed_domains), "route_impact_count": len(route_impacts), "unique_upstream_count": len(upstreams), "tls_certificate_path_count": domain_tls_report.get("summary", {}).get( "unique_certificate_path_count", 0, ), "certificate_owner_confirmation_required_count": domain_tls_report.get("summary", {}).get( "certificate_owner_confirmation_required_count", 0, ), "admin_route_domain_count": domain_tls_report.get("summary", {}).get("admin_route_domain_count", 0), "websocket_route_domain_count": domain_tls_report.get("summary", {}).get( "websocket_route_domain_count", 0, ), "acme_challenge_domain_count": domain_tls_report.get("summary", {}).get( "acme_challenge_domain_count", 0, ), "preflight_gate_count": len(REQUIRED_PREFLIGHT_GATES), "repo_only_preflight_ready_count": repo_only_ready_count, "owner_acceptance_required_gate_count": owner_required_count, "preflight_gate_accepted_count": 0, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "owner_provided_live_conf_received_count": 0, "rendered_diff_ready_count": 0, "nginx_test_evidence_count": 0, "route_smoke_evidence_count": 0, "maintenance_window_accepted_count": 0, "rollback_owner_accepted_count": 0, "runtime_gate_count": 0, "action_button_count": 0, "coverage_percent_before_preflight": 78, "coverage_percent_after_preflight": 84, } return { "schema_version": "public_gateway_preflight_inventory_v1", "generated_at": report_time, "status": "repo_only_preflight_contract_ready", "source_scope": "committed_nginx_and_domain_tls_snapshots_only", "git_commit": git_short_sha(root), "source_reports": [ "docs/security/nginx-config-drift-repo.snapshot.json", "docs/security/domain-tls-certbot-inventory.snapshot.json", ], "summary": summary, "execution_boundaries": EXECUTION_BOUNDARIES, "required_preflight_gates": REQUIRED_PREFLIGHT_GATES, "config_preflight_rows": config_rows, "route_impacts": route_impacts, "unique_upstreams": upstreams, "required_owner_fields": [ "owner_role_or_team", "decision", "decision_reason", "affected_scope", "redacted_evidence_refs", "followup_owner", "rollback_owner", "maintenance_window", "validation_plan", "nginx_test_evidence_ref", "route_smoke_evidence_ref", ], "next_collection_order": [ "public_gateway_owner_response", "owner_provided_live_conf_export", "source_to_live_rendered_diff", "nginx_t_evidence", "affected_public_route_smoke", "admin_route_smoke_if_affected", "websocket_or_api_smoke_if_affected", "tls_certificate_owner_confirmation", "maintenance_window", "rollback_owner_and_ref", ], "operator_interpretation": [ "這是 Nginx public gateway 變更前置控管清冊,不是 reload 授權。", "repo-only ready 只代表 source hash 與 affected route list 已能重跑產生。", "owner response、live conf、rendered diff、nginx -t、route smoke、maintenance window 與 rollback owner 全部仍為 0。", "若未來發現 live drift,只能建立 evidence 與 owner decision,不得自動覆寫 live。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS public gateway 變更前置 Gate 只讀清冊") parser.add_argument("--root", default=".", help="repo root") parser.add_argument( "--nginx-report", default="docs/security/nginx-config-drift-repo.snapshot.json", help="Nginx repo-only drift detector snapshot", ) parser.add_argument( "--domain-tls-report", default="docs/security/domain-tls-certbot-inventory.snapshot.json", help="DNS / TLS / certbot repo-only inventory snapshot", ) parser.add_argument("--output", help="寫出 JSON 報告") parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") args = parser.parse_args() root = Path(args.root).resolve() report = build_report( root, load_json(root / args.nginx_report), load_json(root / args.domain_tls_report), args.generated_at, ) payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) if args.output: output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(payload + "\n", encoding="utf-8") else: print(payload) summary = report["summary"] print( "PUBLIC_GATEWAY_PREFLIGHT_INVENTORY_OK " f"configs={summary['source_config_count']} " f"routes={summary['route_impact_count']} " f"gates={summary['preflight_gate_count']} " f"runtime_gate={summary['runtime_gate_count']}", file=sys.stderr, ) return 0 if __name__ == "__main__": sys.exit(main())