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