377 lines
14 KiB
Python
377 lines
14 KiB
Python
#!/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())
|