#!/usr/bin/env python3 """Check candidate configs and missing routes using only a disposable local Nginx.""" import argparse import json import os import platform import shutil import signal import socket import subprocess import tempfile import time from datetime import datetime, timezone from pathlib import Path from urllib.error import HTTPError, URLError from urllib.request import urlopen def main(): parser = argparse.ArgumentParser() parser.add_argument('--output', required=True) output = Path(parser.parse_args().output) if output.exists(): raise SystemExit('Existing evidence will not be overwritten.') nginx = shutil.which('nginx') or '/usr/sbin/nginx' proc = None with tempfile.TemporaryDirectory(prefix='punchblog-candidate-') as directory: root = Path(directory) web = root / 'www' web.mkdir() (web / 'index.html').write_text('home-v1\n') (web / 'article.html').write_text('article-v1\n') with socket.socket() as listener: listener.bind(('127.0.0.1', 0)) port = listener.getsockname()[1] conf = f'''pid {root}/nginx.pid; error_log {root}/error.log; daemon off; worker_processes 1; events {{ worker_connections 32; }} http {{ access_log off; client_body_temp_path {root}/body; proxy_temp_path {root}/proxy; fastcgi_temp_path {root}/fastcgi; uwsgi_temp_path {root}/uwsgi; scgi_temp_path {root}/scgi; server {{ listen 127.0.0.1:{port}; root {web}; location / {{ try_files $uri $uri/ =404; }} location /fallback/ {{ try_files $uri /index.html; }} location = /version {{ return 200 'config-v1'; }} }} }} ''' active = root / 'nginx.conf' active.write_text(conf) def check(path): result = subprocess.run([nginx, '-t', '-p', str(root) + '/', '-c', str(path)], capture_output=True, text=True, timeout=5) return {'exit': result.returncode, 'stderr': result.stderr.replace(str(root), 'LAB_ROOT')} def fetch(path): try: response = urlopen(f'http://127.0.0.1:{port}{path}', timeout=2) except HTTPError as error: response = error with response: body = response.read().decode() return {'status': response.status, 'body': body if response.status == 200 else 'Nginx 404 page', 'returns_home': body == 'home-v1\n'} assert check(active)['exit'] == 0 try: proc = subprocess.Popen([nginx, '-p', str(root) + '/', '-c', str(active)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True) for _ in range(50): try: before = fetch('/version') break except URLError: time.sleep(.05) else: raise RuntimeError('Isolated Nginx did not start') routes = {path: fetch(path) for path in [ '/article.html', '/missing-post/', '/assets/missing.css', '/fallback/missing-post/', '/fallback/assets/missing.css']} bad = root / 'invalid.conf' bad.write_text(conf + '\nnot_a_real_nginx_directive on;\n') invalid = check(bad) after_invalid = fetch('/version') candidate = root / 'candidate.conf' candidate.write_text(conf.replace('config-v1', 'config-v2')) valid = check(candidate) before_apply = fetch('/version') assert valid['exit'] == 0 os.replace(candidate, active) proc.send_signal(signal.SIGHUP) for _ in range(50): after_apply = fetch('/version') if after_apply['body'] == 'config-v2': break time.sleep(.05) assert invalid['exit'] == 1 and 'unknown directive' in invalid['stderr'] assert before['body'] == after_invalid['body'] == before_apply['body'] == 'config-v1' assert after_apply['body'] == 'config-v2' assert routes['/article.html']['status'] == 200 assert routes['/missing-post/']['status'] == routes['/assets/missing.css']['status'] == 404 assert routes['/fallback/missing-post/']['returns_home'] assert routes['/fallback/assets/missing.css']['returns_home'] finally: if proc is not None: if proc.poll() is None: proc.send_signal(signal.SIGQUIT) try: proc.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(proc.pid, signal.SIGTERM) proc.wait(timeout=5) # Only the process group created by this script can be targeted. try: os.killpg(proc.pid, signal.SIGTERM) except ProcessLookupError: pass record = { 'schema_version': 1, 'observed_at': datetime.now(timezone.utc).isoformat(), 'environment': {'os': platform.system(), 'python': platform.python_version(), 'nginx': subprocess.run([nginx, '-v'], capture_output=True, text=True).stderr.strip()}, 'method': '고유 임시 경로·루프백 포트의 Nginx. 후보 검사와 교체, 해당 master에만 HUP, 순차 GET 요청. 운영 설정·서비스·외부 API를 사용하지 않았다.', 'limits': '단일 실행. 무중단성·동시 접속·잘못된 설정의 실제 reload·CDN·브라우저 라우터·검색엔진의 soft 404 판정은 시험하지 않았다.', 'observations': {'invalid': invalid, 'valid': valid, 'before': before, 'after_invalid': after_invalid, 'before_apply': before_apply, 'after_apply': after_apply, 'routes': routes, 'own_master_exit': proc.returncode}, 'tables': { 'candidate': {'headers': ['단계', '검사 종료 코드', '/version 응답'], 'rows': [ ['잘못된 후보만 검사', invalid['exit'], after_invalid['body']], ['수정한 후보만 검사', valid['exit'], before_apply['body']], ['검증된 후보를 교체하고 로컬 master에 HUP', '검사 후 적용', after_apply['body']]]}, 'routes': {'headers': ['요청 경로', 'HTTP 상태', '홈 본문과 일치'], 'rows': [ [path, value['status'], '일치' if value['returns_home'] else '아님'] for path, value in routes.items()]}}} output.parent.mkdir(parents=True, exist_ok=True) with output.open('x') as stream: json.dump(record, stream, ensure_ascii=False, indent=2) stream.write('\n') print(json.dumps(record['tables'], ensure_ascii=False)) if __name__ == '__main__': main()