#!/usr/bin/env python3 """ IwoooS DNS / TLS / certbot 只讀清冊。 本工具只讀取已提交的 Nginx repo-only drift snapshot,整理 domain、TLS certificate path、ACME challenge、upstream、admin route 與 WebSocket 影響面。 它不做 DNS 查詢、不連線 TLS、不執行 certbot、不 SSH、不 reload Nginx。 """ from __future__ import annotations import argparse import json import re import subprocess import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) TIER_ORDER = {"C0": 0, "C1": 1, "C2": 2, "C3": 3} CERT_DOMAIN_RE = re.compile(r"/etc/letsencrypt/live/([^/]+)/") 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 cert_domain(path: str) -> str | None: match = CERT_DOMAIN_RE.search(path) if not match: return None return match.group(1) def cert_domain_matches(domain: str, candidate: str) -> bool: if candidate == domain: return True if candidate.startswith("*.") and domain.endswith(candidate[1:]): return True return False def strongest_tier(tiers: set[str]) -> str: if not tiers: return "C3" return sorted(tiers, key=lambda tier: TIER_ORDER.get(tier, 99))[0] def ensure_domain(domains: dict[str, dict[str, Any]], domain: str) -> dict[str, Any]: if domain not in domains: domains[domain] = { "domain": domain, "hosts": set(), "config_ids": set(), "source_paths": set(), "live_paths": set(), "control_tiers": set(), "server_block_indexes": set(), "listens": set(), "certificate_paths": set(), "certificate_key_paths": set(), "certificate_path_domains": set(), "acme_challenge_roots": set(), "upstreams": set(), "admin_route_count": 0, "websocket_route_count": 0, } return domains[domain] def add_server_to_domain( domain_item: dict[str, Any], config: dict[str, Any], server: dict[str, Any], ) -> None: domain_item["hosts"].add(config["host"]) domain_item["config_ids"].add(config["config_id"]) domain_item["source_paths"].add(config["repo_source_path"]) domain_item["live_paths"].add(config["live_path"]) domain_item["control_tiers"].add(config["control_tier"]) domain_item["server_block_indexes"].add(f"{config['config_id']}#{server['index']}") domain_item["listens"].update(server.get("listens", [])) domain_item["certificate_paths"].update(server.get("ssl_certificates", [])) domain_item["certificate_key_paths"].update(server.get("ssl_certificate_keys", [])) domain_item["upstreams"].update(server.get("proxy_passes", [])) for path in server.get("ssl_certificates", []): candidate = cert_domain(path) if candidate: domain_item["certificate_path_domains"].add(candidate) for location in server.get("locations", []): path = str(location.get("path", "")) if ".well-known/acme-challenge" in path: domain_item["acme_challenge_roots"].update(location.get("roots", [])) if "/admin" in path: domain_item["admin_route_count"] += 1 if location.get("websocket_upgrade"): domain_item["websocket_route_count"] += 1 def normalize_domain_item(item: dict[str, Any]) -> dict[str, Any]: domain = item["domain"] cert_domains = sorted(item["certificate_path_domains"]) certificate_owner_confirmation_required = bool(cert_domains) and not any( cert_domain_matches(domain, candidate) for candidate in cert_domains ) tls_certificate_path_present = bool(item["certificate_paths"]) if not tls_certificate_path_present: status = "repo_only_tls_path_missing" elif certificate_owner_confirmation_required: status = "repo_only_owner_confirmation_required" else: status = "repo_only_ready_for_owner_review" return { "domain": domain, "hosts": sorted(item["hosts"]), "config_ids": sorted(item["config_ids"]), "source_paths": sorted(item["source_paths"]), "live_paths": sorted(item["live_paths"]), "control_tier": strongest_tier(item["control_tiers"]), "server_block_refs": sorted(item["server_block_indexes"]), "listens": sorted(item["listens"]), "tls_certificate_path_present": tls_certificate_path_present, "certificate_paths": sorted(item["certificate_paths"]), "certificate_key_paths": sorted(item["certificate_key_paths"]), "certificate_path_domains": cert_domains, "certificate_owner_confirmation_required": certificate_owner_confirmation_required, "acme_challenge_present": bool(item["acme_challenge_roots"]), "acme_challenge_roots": sorted(item["acme_challenge_roots"]), "upstreams": sorted(item["upstreams"]), "admin_route_count": item["admin_route_count"], "websocket_route_count": item["websocket_route_count"], "live_tls_probe_status": "not_executed", "dns_resolution_status": "not_executed", "certbot_renewal_status": "not_executed", "owner_review_status": status, } def build_report(root: Path, nginx_report: dict[str, Any], generated_at: str | None) -> dict[str, Any]: domains: dict[str, dict[str, Any]] = {} for config in nginx_report.get("configs", []): parsed = config.get("repo_source", {}).get("parsed", {}) for server in parsed.get("servers", []): for domain in server.get("server_names", []): if not domain or domain == "_": continue item = ensure_domain(domains, domain) add_server_to_domain(item, config, server) managed_domains = [normalize_domain_item(item) for item in domains.values()] managed_domains.sort(key=lambda item: item["domain"]) certificate_paths = sorted( { path for item in managed_domains for path in item.get("certificate_paths", []) } ) owner_confirmation_required = [ item for item in managed_domains if item["certificate_owner_confirmation_required"] or not item["tls_certificate_path_present"] ] acme_domains = [item for item in managed_domains if item["acme_challenge_present"]] admin_domains = [item for item in managed_domains if item["admin_route_count"] > 0] websocket_domains = [item for item in managed_domains if item["websocket_route_count"] > 0] return { "schema_version": "domain_tls_certbot_inventory_v1", "generated_at": generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds"), "mode": "repo_only_from_nginx_source_of_truth", "git_commit": git_short_sha(root), "source_nginx_report": "docs/security/nginx-config-drift-repo.snapshot.json", "execution_boundaries": { "dns_query_executed": False, "live_tls_probe_executed": False, "certbot_renew_executed": False, "nginx_reload_executed": False, "host_write_executed": False, "runtime_gate_opened": False, "secret_value_collected": False, "action_buttons_allowed": False, }, "summary": { "source_config_count": nginx_report.get("summary", {}).get("source_config_count", 0), "managed_domain_count": len(managed_domains), "unique_certificate_path_count": len(certificate_paths), "acme_challenge_domain_count": len(acme_domains), "certificate_owner_confirmation_required_count": len(owner_confirmation_required), "admin_route_domain_count": len(admin_domains), "websocket_route_domain_count": len(websocket_domains), "owner_response_request_sent_count": 0, "owner_response_received_count": 0, "owner_response_accepted_count": 0, "runtime_gate_count": 0, "live_tls_probe_executed": False, "dns_change_executed": False, "certbot_renew_executed": False, "nginx_reload_executed": False, "action_buttons_allowed": False, }, "certificate_paths": certificate_paths, "managed_domains": managed_domains, "owner_confirmation_required_domains": [ { "domain": item["domain"], "certificate_path_domains": item["certificate_path_domains"], "tls_certificate_path_present": item["tls_certificate_path_present"], "owner_review_status": item["owner_review_status"], } for item in owner_confirmation_required ], "required_owner_fields": [ "owner_role_or_team", "decision", "decision_reason", "affected_scope", "redacted_evidence_refs", "followup_owner", "rollback_owner", "maintenance_window", "validation_plan", ], "next_steps": [ "請 owner 確認 certificate path 是否由 SAN 或 wildcard 合法覆蓋;未確認前不得 renew 或 reload。", "未來若要做 live TLS / DNS probe,需另行 scope approval;本清冊只保留 repo-only 證據。", "任何 certbot renew、Nginx reload 或 DNS 變更都必須另開維護窗口、rollback owner 與 post-check。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS DNS / TLS / certbot 只讀清冊") 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("--output", help="寫出 JSON 報告") parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") args = parser.parse_args() root = Path(args.root).resolve() nginx_report_path = root / args.nginx_report nginx_report = load_json(nginx_report_path) report = build_report(root, nginx_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( "DOMAIN_TLS_CERTBOT_INVENTORY_OK " f"domains={summary['managed_domain_count']} " f"cert_paths={summary['unique_certificate_path_count']} " f"owner_confirm={summary['certificate_owner_confirmation_required_count']} " f"runtime_gate={summary['runtime_gate_count']}", file=sys.stderr, ) return 0 if __name__ == "__main__": sys.exit(main())