#!/usr/bin/env python3 """صدور/گسترش گواهی Let's Encrypt برای یک دامنه tenant و فعال‌سازی redirect HTTPS. اجرا روی سرور nginx (با root/sudo): python3 /opt/torbatyar/bin/provision_ssl.py example.torbatyar.ir """ from __future__ import annotations import fcntl import os import re import subprocess import sys from pathlib import Path CERT_NAME = os.environ.get("TORBATYAR_CERT_NAME", "torbatyar.ir") CERT_PATH = Path(f"/etc/letsencrypt/live/{CERT_NAME}/fullchain.pem") WEBROOT = os.environ.get("TORBATYAR_WEBROOT", "/var/www/html") EMAIL = os.environ.get("TORBATYAR_CERTBOT_EMAIL", "support@torbatyar.ir") MAP_FILE = Path( os.environ.get("TORBATYAR_SSL_MAP", "/etc/nginx/conf.d/torbatyar-tenant-ssl.map") ) LOCK_FILE = Path("/var/lock/torbatyar-ssl-provision.lock") PLATFORM_HOSTS = { "torbatyar.ir", "www.torbatyar.ir", "api.torbatyar.ir", "identity.torbatyar.ir", "auth.torbatyar.ir", "accounting.torbatyar.ir", } def die(msg: str, code: int = 1) -> None: print(f"ERROR: {msg}", file=sys.stderr) raise SystemExit(code) def normalize_domain(raw: str) -> str: host = raw.strip().lower().split("/")[0].split(":")[0] if not re.fullmatch(r"[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+", host): die(f"invalid domain: {raw}") return host def current_sans() -> set[str]: if not CERT_PATH.exists(): return set(PLATFORM_HOSTS) out = subprocess.check_output( ["openssl", "x509", "-in", str(CERT_PATH), "-noout", "-ext", "subjectAltName"], text=True, ) found = set(re.findall(r"DNS:([^,\s]+)", out)) return found or set(PLATFORM_HOSTS) def ensure_map_entry(domain: str) -> None: MAP_FILE.parent.mkdir(parents=True, exist_ok=True) if MAP_FILE.exists(): text = MAP_FILE.read_text(encoding="utf-8") else: text = ( "# Auto-managed by provision_ssl.py — do not edit by hand\n" "map $host $torbatyar_tenant_ssl {\n" " default 0;\n" "}\n" ) if re.search(rf"(?m)^\s*{re.escape(domain)}\s+1\s*;", text): return if "map $host $torbatyar_tenant_ssl" not in text: die(f"unexpected map file format: {MAP_FILE}") # درج قبل از بستن } text = re.sub( r"(map \$host \$torbatyar_tenant_ssl \{.*?)(\n\})", rf"\1\n {domain} 1;\2", text, count=1, flags=re.S, ) MAP_FILE.write_text(text, encoding="utf-8") print(f"map_updated={domain}") def run_certbot(domains: list[str]) -> None: cmd = [ "certbot", "certonly", "--webroot", "-w", WEBROOT, "--expand", "--non-interactive", "--agree-tos", "--email", EMAIL, "--cert-name", CERT_NAME, ] for d in domains: cmd.extend(["-d", d]) print("certbot:", " ".join(cmd)) subprocess.check_call(cmd) def reload_nginx() -> None: subprocess.check_call(["nginx", "-t"]) subprocess.check_call(["systemctl", "reload", "nginx"]) print("nginx_reloaded") def provision(domain: str) -> None: domain = normalize_domain(domain) LOCK_FILE.parent.mkdir(parents=True, exist_ok=True) with LOCK_FILE.open("a+", encoding="utf-8") as lock: fcntl.flock(lock.fileno(), fcntl.LOCK_EX) sans = current_sans() if domain not in sans: ordered = sorted(PLATFORM_HOSTS | sans | {domain}) # platform hosts اول برای پایداری cert-name preferred = [h for h in sorted(PLATFORM_HOSTS) if h in ordered] rest = [h for h in ordered if h not in preferred] run_certbot(preferred + rest) else: print(f"cert_already_has={domain}") if domain not in PLATFORM_HOSTS: ensure_map_entry(domain) reload_nginx() print(f"OK {domain}") def main() -> None: if len(sys.argv) != 2: die(f"usage: {sys.argv[0]} ") if os.geteuid() != 0: die("must run as root") provision(sys.argv[1]) if __name__ == "__main__": main()