#!/usr/bin/env python3
"""
Let's Encrypt 证书自动续期 (Python) — 纯 stdlib + openssl + curl，零 PHP 依赖
用法：python3 cert_renew.py --email=admin@example.com [--force] [--dry-run] [--staging]
crontab：0 3 * * * python3 /path/to/cert_renew.py --email=x@x.com
"""

import os, sys, json, time, base64, hashlib, subprocess, re, argparse
from datetime import datetime, timezone

# ── CLI ──
ap = argparse.ArgumentParser(description="Let's Encrypt 证书自动续期")
ap.add_argument('--email', default='')
ap.add_argument('--force', action='store_true')
ap.add_argument('--dry-run', action='store_true')
ap.add_argument('--staging', action='store_true')
args = ap.parse_args()

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
WEB_ROOT    = os.environ.get('DOCUMENT_ROOT', os.path.dirname(SCRIPT_DIR))
DATA_DIR    = os.path.join(WEB_ROOT, 'ssl_certs')
ACCT_DIR    = os.path.join(DATA_DIR, 'account')
CHALLENGE_DIR = os.path.join(WEB_ROOT, '.well-known', 'acme-challenge')
ACCT_KEY    = os.path.join(ACCT_DIR, 'private.pem')

DIR_URL = 'https://acme-staging-v02.api.letsencrypt.org/directory' if args.staging \
     else 'https://acme-v02.api.letsencrypt.org/directory'

# ── helpers ──
def b64u(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()

def _sh(cmd, stdin=None):
    return subprocess.run(cmd, shell=True, capture_output=True, input=stdin, text=True)

_nonce_cache = None
def get_nonce():
    global _nonce_cache
    if _nonce_cache: n, _nonce_cache = _nonce_cache, None; return n
    r = subprocess.run(['curl', '-sI', DIR_URL], capture_output=True, text=True)
    m = re.search(r'Replay-Nonce:\s*(\S+)', r.stdout, re.I)
    if m: return m.group(1).strip()
    f = os.path.join(ACCT_DIR, 'nonce')
    if os.path.exists(f): return open(f).read().strip()
    raise RuntimeError('无法获取 Replay-Nonce')

def save_nonce(headers: str):
    global _nonce_cache
    m = re.search(r'(?i)^Replay-Nonce:\s*(\S+)', headers, re.M)
    if m: _nonce_cache = m.group(1).strip()

def get_jwk() -> dict:
    """从账户私钥提取 RSA JWK"""
    # 导出 DER 公钥
    der = subprocess.check_output(
        ['openssl', 'rsa', '-pubout', '-in', ACCT_KEY, '-outform', 'DER'],
        stderr=subprocess.DEVNULL)
    # asn1parse
    out = subprocess.check_output(
        ['openssl', 'asn1parse', '-inform', 'DER'],
        input=der, stderr=subprocess.DEVNULL).decode()
    ints = []
    for line in out.splitlines():
        if 'INTEGER' in line:
            m = re.search(r'hl=\d+\s+l=\s*(\d+)\s+.*:(\w+)', line)
            if m: ints.append((int(m.group(1)), m.group(2)))
    n_hex, e_hex = ints[0][1], ints[1][1]
    return {
        'kty': 'RSA',
        'n': b64u(bytes.fromhex(n_hex)),
        'e': b64u(bytes.fromhex(e_hex)),
    }

def thumbprint() -> str:
    jwk = get_jwk()
    canon = json.dumps(jwk, sort_keys=True, separators=(',', ':'))
    return b64u(hashlib.sha256(canon.encode()).digest())

def acme_request(url: str, payload, kid='') -> dict:
    """发送 JWS 签名的 ACME 请求"""
    nonce = get_nonce()
    header = {'alg': 'RS256', 'nonce': nonce, 'url': url}
    if kid: header['kid'] = kid
    else:    header['jwk'] = get_jwk()

    payload_json = json.dumps(payload) if payload else ''
    prot64 = b64u(json.dumps(header).encode())
    payl64 = b64u(payload_json.encode()) if payload_json else ''
    si = f'{prot64}.{payl64}'

    # sign with openssl
    sig = subprocess.check_output(
        ['openssl', 'dgst', '-sha256', '-sign', ACCT_KEY],
        input=si.encode(), stderr=subprocess.DEVNULL)
    sig64 = b64u(sig)

    jws = json.dumps({'protected': prot64, 'payload': payl64, 'signature': sig64})

    # send with curl, capturing headers
    tmpfile = '/tmp/acme_resp_headers.txt'
    r = subprocess.run(
        ['curl', '-s', '-w', '%{http_code}', '-D', tmpfile, '-X', 'POST', url,
         '-H', 'Content-Type: application/jose+json', '-d', jws],
        capture_output=True, text=True, timeout=30)
    if os.path.exists(tmpfile):
        save_nonce(open(tmpfile).read())
        os.remove(tmpfile)

    code = int(r.stdout[-3:]) if len(r.stdout) >= 3 else 500
    body = r.stdout[:-3] if len(r.stdout) > 3 else r.stdout

    if code >= 400:
        try: err = json.loads(body).get('detail', body)
        except: err = body
        raise RuntimeError(f'ACME HTTP {code}: {err}')

    return json.loads(body) if body else {}


def obtain(domain: str, email: str) -> bool:
    """完整的 ACME 证书申请流程"""
    try:
        # 0. directory
        dir_data = json.loads(subprocess.check_output(['curl', '-s', DIR_URL], text=True))
        newAcct, newOrd = dir_data['newAccount'], dir_data['newOrder']

        # 1. account
        kid_file = os.path.join(ACCT_DIR, 'account_url.txt')
        if os.path.exists(kid_file):
            kid = open(kid_file).read().strip()
            print(f'   复用账户: {kid}')
        else:
            kid = _create_account(newAcct, email)
            os.makedirs(ACCT_DIR, exist_ok=True)
            with open(kid_file, 'w') as f: f.write(kid)
            with open(os.path.join(ACCT_DIR, 'email.txt'), 'w') as f: f.write(email)

        # 2. order
        resp = acme_request(newOrd, {'identifiers': [{'type': 'dns', 'value': domain}]}, kid)
        fin_url, ord_url = resp['finalize'], resp.get('url', '')
        # get authorization URLs
        for auth_url in resp['authorizations']:
            auth = acme_request(auth_url, '', kid)
            _complete_challenge(auth, auth_url, kid)

        # 3. CSR
        cert_key = os.path.join(DATA_DIR, domain, 'private.pem')
        os.makedirs(os.path.dirname(cert_key), exist_ok=True)
        subprocess.run(['openssl', 'genrsa', '-out', cert_key, '2048'],
                      capture_output=True, stderr=subprocess.DEVNULL)
        csr_der = subprocess.check_output(
            ['openssl', 'req', '-new', '-sha256', '-key', cert_key,
             '-subj', f'/CN={domain}', '-addext', f'subjectAltName=DNS:{domain}',
             '-outform', 'DER'], stderr=subprocess.DEVNULL)
        acme_request(fin_url, {'csr': b64u(csr_der)}, kid)

        # 4. download cert
        for _ in range(15):
            time.sleep(2)
            o = acme_request(ord_url, '', kid)
            if cert_url := o.get('certificate', ''):
                cert_pem = acme_request(cert_url, '', kid)
                # cert_pem is actually a string (the PEM text)
                with open(os.path.join(DATA_DIR, domain, 'fullchain.pem'), 'w') as f:
                    f.write(cert_pem if isinstance(cert_pem, str) else json.dumps(cert_pem))
                print(f'   [OK] {domain}')
                return True
        print('   签发超时'); return False
    except Exception as e:
        print(f'   [FAIL] {e}'); return False


def _create_account(url, email):
    resp = acme_request(url, {'termsOfServiceAgreed': True, 'contact': [f'mailto:{email}']})
    # 从响应获取 kid（Location 头在再次请求中）
    # 简化：直接再发一次获取 Location
    import tempfile
    hdr_file = os.path.join(tempfile.gettempdir(), 'acme_hdr.txt')
    nonce = get_nonce()
    header = {'alg': 'RS256', 'nonce': nonce, 'url': url, 'jwk': get_jwk()}
    payload = json.dumps({'termsOfServiceAgreed': True, 'contact': [f'mailto:{email}']})
    prot64 = b64u(json.dumps(header).encode())
    payl64 = b64u(payload.encode())
    si = f'{prot64}.{payl64}'
    sig = subprocess.check_output(['openssl', 'dgst', '-sha256', '-sign', ACCT_KEY],
                                  input=si.encode(), stderr=subprocess.DEVNULL)
    jws = json.dumps({'protected': prot64, 'payload': payl64, 'signature': b64u(sig)})
    r = subprocess.run(
        ['curl', '-s', '-o', '/dev/null', '-w', '%{redirect_url}', '-D', hdr_file,
         '-X', 'POST', url, '-H', 'Content-Type: application/jose+json', '-d', jws],
        capture_output=True, text=True)
    kid = r.stdout.strip()
    if not kid:
        # fallback: grep Location header
        if os.path.exists(hdr_file):
            m = re.search(r'(?i)^Location:\s*(\S+)', open(hdr_file).read(), re.M)
            if m: kid = m.group(1)
        os.remove(hdr_file) if os.path.exists(hdr_file) else None
    if not kid:
        raise RuntimeError('无法获取账户 URL')
    return kid


def _complete_challenge(auth, auth_url, kid):
    challenges = [c for c in auth.get('challenges', []) if c['type'] == 'http-01']
    if not challenges: raise RuntimeError('无 HTTP-01 挑战')
    ch = challenges[0]
    token, ch_url = ch['token'], ch['url']
    ka = f'{token}.{thumbprint()}'

    os.makedirs(CHALLENGE_DIR, exist_ok=True)
    fpath = os.path.join(CHALLENGE_DIR, token)
    with open(fpath, 'w') as f: f.write(ka)

    try:
        acme_request(ch_url, {}, kid)
        for _ in range(12):
            time.sleep(2)
            st = acme_request(auth_url, '', kid).get('status', '')
            if st == 'valid': break
            if st == 'invalid': raise RuntimeError('验证失败')
    finally:
        if os.path.exists(fpath): os.remove(fpath)


# ── main ──
print(f'{datetime.now():%Y-%m-%d %H:%M:%S} 证书续期检查 (Python ACME)')
print(f'环境：{"Staging" if args.staging else "Production"}')
if args.dry_run: print('** 试运行 **')

if not args.email:
    ef = os.path.join(ACCT_DIR, 'email.txt')
    if os.path.exists(ef): args.email = open(ef).read().strip()
if not args.email: sys.exit('ERR: 需要 --email=x@x.com')

os.makedirs(ACCT_DIR, exist_ok=True)
if not os.path.exists(ACCT_KEY):
    subprocess.run(['openssl', 'genrsa', '-out', ACCT_KEY, '2048'], capture_output=True)

renewed = skipped = errors = 0
try:
    for entry in sorted(os.listdir(DATA_DIR)):
        cert_path = os.path.join(DATA_DIR, entry, 'fullchain.pem')
        if not os.path.isfile(cert_path): continue
        if entry == 'account': continue

        r = subprocess.run(['openssl', 'x509', '-in', cert_path, '-noout', '-enddate'],
                          capture_output=True, text=True)
        m = re.search(r'notAfter=(.+)', r.stdout)
        if not m: skipped += 1; continue
        end = datetime.strptime(m.group(1).strip(), '%b %d %H:%M:%S %Y %Z')
        days = max(0, (end - datetime.now()).days)
        print(f'\n{entry} (剩余{days}天)')

        if not args.force and days > 30: skipped += 1; continue
        if args.dry_run: renewed += 1; continue

        if obtain(entry, args.email): renewed += 1
        else: errors += 1
except FileNotFoundError:
    pass

print(f'\n{datetime.now():%Y-%m-%d %H:%M:%S} 续期:{renewed} 跳过:{skipped} 失败:{errors}')
sys.exit(1 if errors else 0)
