#!/usr/bin/env bash
#===============================================================================
# Let's Encrypt 证书自动续期 (Bash) — 纯 Shell，零 PHP/Python 依赖
# 依赖：openssl curl (xxd 可选，base64 替代)
# crontab：0 3 * * * bash /path/to/cert_renew.sh --email=x@x.com
#===============================================================================
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WEB_ROOT="${DOCUMENT_ROOT:-$(dirname "$SCRIPT_DIR")}"
DATA_DIR="$WEB_ROOT/ssl_certs"
ACCT_DIR="$DATA_DIR/account"
: "${CHALLENGE_DIR:=$WEB_ROOT/.well-known/acme-challenge}"

EMAIL=""; FORCE=0; DRY=0; STAGING=0
for a; do case $a in
    --email=*) EMAIL="${a#*=}" ;; --force) FORCE=1 ;; --dry-run) DRY=1 ;;
    --staging) STAGING=1 ;; *) echo "? $a" >&2; exit 1 ;;
esac; done

DIR_URL="$([ "$STAGING" = 1 ] && echo 'https://acme-staging-v02.api.letsencrypt.org/directory' || echo 'https://acme-v02.api.letsencrypt.org/directory')"

# ─────────── helpers ───────────
b64u()  { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
_nonce() {
    local n; n=$(curl -sI "$DIR_URL" 2>/dev/null | sed -n 's/^Replay-Nonce: *//Ip' | tr -d '\r')
    [ -z "$n" ] && [ -f "$ACCT_DIR/nonce" ] && n=$(cat "$ACCT_DIR/nonce")
    echo "$n"
}
_jwk() {
    # extract RSA modulus (n) + exponent (e) from account key, return JSON
    python3 -c "
import subprocess as sp, base64, json, re
pem=open('$ACCOUNT_KEY').read()
der=sp.check_output(['openssl','rsa','-pubout','-outform','DER'],input=pem.encode(),stderr=sp.DEVNULL)
out=sp.check_output(['openssl','asn1parse','-inform','DER'],input=der).decode()
ints=[]; start=0
for l in out.splitlines():
    if 'INTEGER' in l:
        m=re.search(r'hl=\d+\s+l=\s*(\d+)\s+.*:(\w+)',l)
        if m: ints.append((int(m.group(1)),m.group(2)))
# modulus (mod[0]), exponent (mod[1]) — each is hex
n_hex=ints[0][1]; e_hex=ints[1][1]
n=base64.urlsafe_b64encode(bytes.fromhex(n_hex)).rstrip(b'=').decode()
e=base64.urlsafe_b64encode(bytes.fromhex(e_hex)).rstrip(b'=').decode()
print(json.dumps({'kty':'RSA','n':n,'e':e}))
"
}
_thumb() {
    local jwk; jwk=$(_jwk)
    local canon; canon=$(python3 -c "import json;d=json.loads('$jwk');print(json.dumps(d,sort_keys=True,separators=(',',':')))")
    echo -n "$canon" | openssl dgst -sha256 -binary | b64u
}
_req() {
    # $1=url $2=payload_json $3=kid
    local url="$1" pyld="$2" kid="${3:-}" nonce; nonce=$(_nonce)
    [ -z "$nonce" ] && { echo "NO_NONCE" >&2; return 1; }

    local hdr; hdr=$(python3 -c "
import json
h={'alg':'RS256','nonce':'$nonce','url':'$url'}
if '$kid': h['kid']='$kid'
else:
    jwk=json.loads('$(_jwk)')
    h['jwk']=jwk
print(json.dumps(h))
")
    local p64; p64=$(echo -n "$hdr" | b64u)
    local y64=""; [ -n "$pyld" ] && y64=$(echo -n "$pyld" | b64u)
    local si="${p64}.${y64}"
    local sig; sig=$(echo -n "$si" | openssl dgst -sha256 -sign "$ACCOUNT_KEY" | b64u)
    local body; body=$(python3 -c "import json;print(json.dumps({'protected':'$p64','payload':'$y64','signature':'$sig'}))")

    # send, capture headers+body
    local tmp; tmp=$(mktemp)
    local code; code=$(curl -s -o "$tmp" -w '%{http_code}' -D - -X POST "$url" \
        -H 'Content-Type: application/jose+json' -d "$body" 2>/dev/null | {
        while IFS= read -r line; do
            [[ "$line" =~ ^Replay-Nonce:\ *([^$'\r']+) ]] && echo "${BASH_REMATCH[1]}" > "$ACCT_DIR/nonce"
        done
        cat
    } | tail -1)
    local ans; ans=$(tail -c +2 "$tmp" 2>/dev/null || cat "$tmp")
    rm -f "$tmp"

    if [ "$code" -ge 400 ]; then
        local err; err=$(echo "$ans" | python3 -c "import json,sys;print(json.load(sys.stdin).get('detail',''))" 2>/dev/null || echo "$ans")
        echo "HTTP $code: $err" >&2; return 1
    fi
    # save nonce from response header
    grep -ai '^Replay-Nonce:' "$tmp" 2>/dev/null | head -1 | sed 's/.*: //' | tr -d '\r' > "$ACCT_DIR/nonce" 2>/dev/null || true
    echo "$ans"
}

# ─────────── ACME flow ───────────
obtain() {
    local domain="$1" email="$2"; echo "   ACME: $domain"

    # 0. directory
    local dir; dir=$(curl -s "$DIR_URL")
    local newAcct; newAcct=$(echo "$dir" | python3 -c "import json,sys;print(json.load(sys.stdin)['newAccount'])")
    local newOrd;  newOrd=$(echo "$dir" | python3 -c "import json,sys;print(json.load(sys.stdin)['newOrder'])")

    # 1. account
    local kid=""
    if [ -f "$ACCT_DIR/account_url.txt" ]; then
        kid=$(cat "$ACCT_DIR/account_url.txt")
        echo "   复用账户"
    else
        local pl; pl=$(python3 -c "import json;print(json.dumps({'termsOfServiceAgreed':True,'contact':['mailto:$email']}))")
        local resp; resp=$(_req "$newAcct" "$pl" "") || { echo "   $resp"; return 1; }
        kid=$(echo "$resp" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('headers',{}).get('location',[''])[0] or '')" 2>/dev/null)
        [ -z "$kid" ] && { echo "   无法获取账户URL"; return 1; }
        echo "$kid" > "$ACCT_DIR/account_url.txt"
        echo "$email" > "$ACCT_DIR/email.txt"
    fi

    # 2. order
    local pl; pl=$(python3 -c "import json;print(json.dumps({'identifiers':[{'type':'dns','value':'$domain'}]}))")
    local ord; ord=$(_req "$newOrd" "$pl" "$kid") || { echo "   $ord"; return 1; }
    local finUrl; finUrl=$(echo "$ord" | python3 -c "import json,sys;print(json.load(sys.stdin)['finalize'])")
    local ordUrl; ordUrl=$(echo "$ord" | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('url',d.get('headers',{}).get('location',[''])[0]))")

    # 3. challenge
    local authUrls; authUrls=$(echo "$ord" | python3 -c "import json,sys;[print(a) for a in json.load(sys.stdin)['authorizations']]")
    mkdir -p "$CHALLENGE_DIR"
    while IFS= read -r au; do
        [ -z "$au" ] && continue
        local auth; auth=$(_req "$au" "" "$kid") || { echo "   授权失败"; return 1; }
        local ch; ch=$(echo "$auth" | python3 -c "
import json,sys
cs=[c for c in json.load(sys.stdin).get('challenges',[]) if c['type']=='http-01']
print(json.dumps(cs[0]) if cs else '')
")
        [ -z "$ch" ] && { echo "   无http-01挑战"; return 1; }
        local token; token=$(echo "$ch" | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")
        local chUrl; chUrl=$(echo "$ch" | python3 -c "import json,sys;print(json.load(sys.stdin)['url'])")
        local ka; ka="${token}.$(_thumb)"
        echo -n "$ka" > "$CHALLENGE_DIR/$token"
        _req "$chUrl" '{}' "$kid" > /dev/null 2>&1 || true
        # poll
        for _ in $(seq 1 12); do
            sleep 2
            st=$(_req "$au" "" "$kid" 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin).get('status',''))" 2>/dev/null)
            [ "$st" = "valid" ] && break
            [ "$st" = "invalid" ] && { rm -f "$CHALLENGE_DIR/$token"; echo "   验证失败"; return 1; }
        done
        rm -f "$CHALLENGE_DIR/$token"
    done <<< "$authUrls"

    # 4. CSR
    local certKey="$DATA_DIR/$domain/private.pem"
    mkdir -p "$DATA_DIR/$domain"
    openssl genrsa -out "$certKey" 2048 2>/dev/null
    local csr; csr=$(openssl req -new -sha256 -key "$certKey" -subj "/CN=$domain" \
        -addext "subjectAltName=DNS:$domain" -outform DER 2>/dev/null | b64u)
    local csrPl; csrPl="{\"csr\":\"$csr\"}"
    _req "$finUrl" "$csrPl" "$kid" > /dev/null || { echo "   CSR提交失败"; return 1; }

    # 5. download cert
    local certUrl=""
    for _ in $(seq 1 12); do
        sleep 2
        certUrl=$(_req "$ordUrl" "" "$kid" 2>/dev/null | python3 -c "import json,sys;print(json.load(sys.stdin).get('certificate',''))" 2>/dev/null || echo "")
        [ -n "$certUrl" ] && break
    done
    [ -z "$certUrl" ] && { echo "   签发超时"; return 1; }
    _req "$certUrl" "" "$kid" > "$DATA_DIR/$domain/fullchain.pem"
    echo "   [OK] $domain"
}

# ─────────── main ───────────
echo "$(date '+%F %T') 证书续期检查 (Bash ACME)"
echo "环境：$([ "$STAGING" = 1 ] && echo Staging || echo Production)"
[ "$DRY" = 1 ] && echo '** 试运行 **'

[ -z "$EMAIL" ] && [ -f "$ACCT_DIR/email.txt" ] && EMAIL=$(cat "$ACCT_DIR/email.txt")
[ -z "$EMAIL" ] && { echo "ERR: 需要 --email=x@x.com" >&2; exit 1; }

mkdir -p "$ACCT_DIR"
ACCOUNT_KEY="$ACCT_DIR/private.pem"
[ ! -f "$ACCOUNT_KEY" ] && openssl genrsa -out "$ACCOUNT_KEY" 2048 2>/dev/null

R=0 S=0 E=0
for f in "$DATA_DIR"/*/fullchain.pem; do
    [ -f "$f" ] || continue
    dom=$(basename "$(dirname "$f")"); [ "$dom" = account ] && continue
    ed=$(openssl x509 -in "$f" -noout -enddate 2>/dev/null | sed 's/notAfter=//')
    [ -z "$ed" ] && { S=$((S+1)); continue; }
    ts=$(date -d "$ed" +%s 2>/dev/null || date -j -f "%b %d %T %Y %Z" "$ed" +%s 2>/dev/null)
    dl=$(( (ts - $(date +%s)) / 86400 )); [ "$dl" -lt 0 ] && dl=0
    echo "$dom (剩余${dl}天)"
    if [ "$FORCE" = 0 ] && [ "$dl" -gt 30 ]; then S=$((S+1)); continue; fi
    if [ "$DRY" = 1 ]; then R=$((R+1)); continue; fi
    obtain "$dom" "$EMAIL" && R=$((R+1)) || { E=$((E+1)); echo "   FAIL"; }
done
echo "$(date '+%F %T') 续期:$R 跳过:$S 失败:$E"
[ "$E" -gt 0 ] && exit 1
