#!/usr/bin/env python3 """ IwoooS Nginx 只讀配置漂移偵測器。 本工具只讀取 repo 內的 Nginx source-of-truth,或由 owner 另行提供的 live conf 匯出檔;它不 SSH、不 reload、不寫入主機、不觸發部署。 """ from __future__ import annotations import argparse import hashlib import json import re import subprocess import sys from dataclasses import dataclass from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) @dataclass(frozen=True) class NginxSource: config_id: str host: str role: str source_path: str live_path: str control_tier: str owner_gate: str SOURCES = [ NginxSource( config_id="host188_all_sites", host="192.168.0.188", role="public_gateway_all_sites", source_path="infra/ansible/roles/nginx/templates/188-all-sites.conf.j2", live_path="/etc/nginx/sites-enabled/all-sites.conf", control_tier="C0", owner_gate="public_gateway_owner_response_required", ), NginxSource( config_id="host188_internal_tools_https", host="192.168.0.188", role="public_internal_tools_https", source_path="infra/ansible/roles/nginx/templates/188-internal-tools-https.conf.j2", live_path="owner_confirmation_required", control_tier="C0", owner_gate="public_tools_owner_response_required", ), NginxSource( config_id="host110_ollama_proxy", host="192.168.0.110", role="ollama_proxy_gateway", source_path="infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", live_path="/etc/nginx/sites-enabled/110-ollama-proxy.conf", control_tier="C1", owner_gate="ai_provider_proxy_owner_response_required", ), ] def strip_comments(text: str) -> str: lines: list[str] = [] for line in text.splitlines(): if "#" in line: line = line.split("#", 1)[0] lines.append(line) return "\n".join(lines) def normalized_text(text: str) -> str: clean = strip_comments(text) return "\n".join(line.strip() for line in clean.splitlines() if line.strip()) def sha256_text(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() def match_closing_brace(text: str, open_brace: int) -> int: depth = 0 for index in range(open_brace, len(text)): char = text[index] if char == "{": depth += 1 elif char == "}": depth -= 1 if depth == 0: return index return -1 def named_blocks(text: str, name: str) -> list[tuple[str, str]]: clean = strip_comments(text) if name == "server": pattern = re.compile(r"\bserver\s*\{") else: pattern = re.compile(rf"\b{name}\s+([^{{]+)\{{") blocks: list[tuple[str, str]] = [] for match in pattern.finditer(clean): open_brace = clean.find("{", match.start()) close_brace = match_closing_brace(clean, open_brace) if close_brace == -1: continue args = "" if name != "server": args = (match.group(1) or "").strip() blocks.append((args, clean[match.start() : close_brace + 1])) return blocks def directive_values(block: str, directive: str) -> list[str]: pattern = re.compile(rf"(?ms)^\s*{re.escape(directive)}\s+(.*?);") return [" ".join(match.group(1).split()) for match in pattern.finditer(block)] def split_words(value: str) -> list[str]: return [part for part in re.split(r"\s+", value.strip()) if part] def location_entries(block: str) -> list[dict[str, Any]]: entries: list[dict[str, Any]] = [] for args, body in named_blocks(block, "location"): proxy_passes = directive_values(body, "proxy_pass") roots = directive_values(body, "root") auth_basic = directive_values(body, "auth_basic") entries.append( { "path": args, "proxy_passes": proxy_passes, "roots": roots, "auth_basic": auth_basic, "websocket_upgrade": "Upgrade $http_upgrade" in body or "Connection \"upgrade\"" in body or "Connection $connection_upgrade" in body, } ) return entries def parse_nginx(text: str) -> dict[str, Any]: servers: list[dict[str, Any]] = [] all_server_names: set[str] = set() all_listens: set[str] = set() all_proxy_passes: set[str] = set() all_ssl_certificates: set[str] = set() all_ssl_certificate_keys: set[str] = set() admin_routes: list[dict[str, Any]] = [] acme_routes: list[dict[str, Any]] = [] websocket_routes: list[dict[str, Any]] = [] for index, (_, block) in enumerate(named_blocks(text, "server"), start=1): names = [ word for value in directive_values(block, "server_name") for word in split_words(value) if word and word != "_" ] listens = directive_values(block, "listen") ssl_certs = directive_values(block, "ssl_certificate") ssl_keys = directive_values(block, "ssl_certificate_key") locations = location_entries(block) proxy_passes = [ proxy for location in locations for proxy in location.get("proxy_passes", []) ] all_server_names.update(names) all_listens.update(listens) all_proxy_passes.update(proxy_passes) all_ssl_certificates.update(ssl_certs) all_ssl_certificate_keys.update(ssl_keys) for location in locations: path = str(location["path"]) entry = { "server_names": names, "path": path, "proxy_passes": location.get("proxy_passes", []), "roots": location.get("roots", []), "auth_basic": location.get("auth_basic", []), } if "/admin" in path: admin_routes.append(entry) if ".well-known/acme-challenge" in path: acme_routes.append(entry) if location.get("websocket_upgrade"): websocket_routes.append(entry) servers.append( { "index": index, "server_names": names, "listens": listens, "ssl_certificates": ssl_certs, "ssl_certificate_keys": ssl_keys, "proxy_passes": proxy_passes, "locations": locations, "has_tls": bool(ssl_certs or any("443" in item for item in listens)), } ) return { "server_block_count": len(servers), "server_names": sorted(all_server_names), "listens": sorted(all_listens), "proxy_passes": sorted(all_proxy_passes), "ssl_certificates": sorted(all_ssl_certificates), "ssl_certificate_keys": sorted(all_ssl_certificate_keys), "admin_routes": admin_routes, "acme_routes": acme_routes, "websocket_routes": websocket_routes, "servers": servers, } 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 read_source(path: Path) -> dict[str, Any]: raw = path.read_text(encoding="utf-8") normalized = normalized_text(raw) parsed = parse_nginx(raw) return { "raw_sha256": sha256_text(raw), "normalized_sha256": sha256_text(normalized), "line_count": len(raw.splitlines()), "parsed": parsed, } def compare_sets(source_values: list[str], live_values: list[str]) -> dict[str, list[str]]: source_set = set(source_values) live_set = set(live_values) return { "missing_in_live": sorted(source_set - live_set), "extra_in_live": sorted(live_set - source_set), } def compare_config(source: dict[str, Any], live: dict[str, Any]) -> dict[str, Any]: source_parsed = source["parsed"] live_parsed = live["parsed"] normalized_matches = source["normalized_sha256"] == live["normalized_sha256"] semantic_diff = { "server_names": compare_sets(source_parsed["server_names"], live_parsed["server_names"]), "proxy_passes": compare_sets(source_parsed["proxy_passes"], live_parsed["proxy_passes"]), "ssl_certificates": compare_sets( source_parsed["ssl_certificates"], live_parsed["ssl_certificates"], ), } has_semantic_diff = any( diff["missing_in_live"] or diff["extra_in_live"] for diff in semantic_diff.values() ) return { "normalized_hash_matches": normalized_matches, "semantic_diff": semantic_diff, "drift_detected": (not normalized_matches) or has_semantic_diff, } def parse_live_files(items: list[str]) -> dict[str, Path]: live_files: dict[str, Path] = {} for item in items: if "=" not in item: raise ValueError(f"--live-file 必須使用 config_id=/path 格式:{item}") config_id, raw_path = item.split("=", 1) live_files[config_id.strip()] = Path(raw_path.strip()) return live_files def build_report( root: Path, live_files: dict[str, Path], generated_at: str | None, ) -> dict[str, Any]: report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") configs: list[dict[str, Any]] = [] drift_count = 0 live_input_count = 0 for source in SOURCES: source_path = root / source.source_path source_report = read_source(source_path) live_path = live_files.get(source.config_id) live_report: dict[str, Any] | None = None comparison: dict[str, Any] = { "status": "repo_only_no_live_evidence", "drift_detected": None, "note": "尚未提供 live conf 匯出檔;本階段不 SSH、不讀 live、不 reload。", } if live_path is not None: live_input_count += 1 if not live_path.exists(): comparison = { "status": "live_file_missing", "drift_detected": None, "note": f"找不到 owner 提供的 live conf 匯出檔:{live_path}", } else: live_report = read_source(live_path) comparison = compare_config(source_report, live_report) comparison["status"] = ( "drift_detected" if comparison["drift_detected"] else "matched" ) if comparison["drift_detected"]: drift_count += 1 configs.append( { "config_id": source.config_id, "host": source.host, "role": source.role, "control_tier": source.control_tier, "owner_gate": source.owner_gate, "repo_source_path": source.source_path, "live_path": source.live_path, "repo_source": source_report, "live_input": { "provided": live_path is not None, "path": str(live_path) if live_path else None, "summary": live_report, }, "comparison": comparison, } ) return { "schema_version": "nginx_config_drift_detector_v1", "generated_at": report_time, "mode": "repo_only" if live_input_count == 0 else "compare_owner_provided_live_files", "git_commit": git_short_sha(root), "execution_boundaries": { "ssh_executed": False, "nginx_test_executed": False, "nginx_reload_executed": False, "host_write_executed": False, "runtime_gate_opened": False, "secret_value_collected": False, }, "summary": { "source_config_count": len(configs), "live_input_count": live_input_count, "drift_detected_count": drift_count, "repo_source_inventory_complete": True, "live_evidence_collected": live_input_count > 0, }, "configs": configs, "next_steps": [ "由 owner 提供脫敏 live conf 匯出檔後重跑比較模式。", "若偵測 drift,只建立 evidence 與 owner decision,不自動覆寫 live。", "任何 Nginx reload 仍需 maintenance window、rollback owner、nginx -t 與 route smoke。", ], } def main() -> int: parser = argparse.ArgumentParser(description="IwoooS Nginx 只讀配置漂移偵測器") parser.add_argument("--root", default=".", help="repo root") parser.add_argument("--output", help="寫出 JSON 報告") parser.add_argument( "--live-file", action="append", default=[], help="owner 提供的 live conf 匯出檔,格式:config_id=/path/to/file", ) parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") parser.add_argument("--fail-on-drift", action="store_true", help="偵測到 drift 時回傳 1") args = parser.parse_args() root = Path(args.root).resolve() live_files = parse_live_files(args.live_file) report = build_report(root, live_files, 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) if args.fail_on_drift and report["summary"]["drift_detected_count"] > 0: return 1 return 0 if __name__ == "__main__": sys.exit(main())