TorbatYar/infrastructure/nginx/provision_ssl.py
Mortezakoohjani 12c8615615 Ship enterprise Accounting FE/API with CRUD parity and production wiring.
Adds accounting-service PATCH/archive, fiscal helpers, COA templates and setup status, plus SuperApp Accounting UI (DS, scoreboard, masters, vouchers, ledger, ops modules) with session refresh and HTTPS public API URLs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 15:26:43 +03:30

141 lines
4.2 KiB
Python

#!/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]} <domain>")
if os.geteuid() != 0:
die("must run as root")
provision(sys.argv[1])
if __name__ == "__main__":
main()