#!/usr/bin/env perl
#===============================================================================
# Let's Encrypt 证书自动续期 (Perl) — 纯 Perl + openssl + curl，零 PHP 依赖
# 用法：perl cert_renew.pl --email=admin@example.com [--force] [--dry-run] [--staging]
# crontab：0 3 * * * perl /path/to/cert_renew.pl --email=x@x.com
#===============================================================================
use v5.14;
use strict;
use warnings;
use Getopt::Long;
use File::Spec;
use File::Basename qw(dirname basename);
use File::Path qw(mkpath);
use Cwd qw(abs_path);
use JSON::PP;
use Digest::SHA qw(sha256);
use MIME::Base64 qw(encode_base64 decode_base64);
use Time::Piece;
use Time::Seconds;

# ── args ──
my ($email, $force, $dry_run, $staging) = ('', 0, 0, 0);
GetOptions('email=s' => \$email, 'force' => \$force, 'dry-run' => \$dry_run, 'staging' => \$staging)
    or die "用法: perl cert_renew.pl --email=admin\@x.com [--force] [--dry-run] [--staging]\n";

my $script_dir  = abs_path(dirname($0));
my $web_root    = $ENV{DOCUMENT_ROOT} || dirname($script_dir);
my $data_dir    = "$web_root/ssl_certs";
my $acct_dir    = "$data_dir/account";
my $ch_dir      = "$web_root/.well-known/acme-challenge";
my $acct_key    = "$acct_dir/private.pem";

my $DIR_URL = $staging
    ? 'https://acme-staging-v02.api.letsencrypt.org/directory'
    : 'https://acme-v02.api.letsencrypt.org/directory';

# ── helpers ──
sub b64u { my $d = shift; $d = encode_base64($d, ''); $d =~ tr{+/}{-_}; $d =~ s{=+$}{}; return $d; }
sub b64u_str { b64u(shift) }

my $_nonce_cache;
sub get_nonce {
    return $_nonce_cache if $_nonce_cache;
    my $h = `curl -sI '$DIR_URL' 2>/dev/null`;
    if ($h =~ /^Replay-Nonce:\s*(\S+)/im) { $_nonce_cache = $1; return $1; }
    my $f = "$acct_dir/nonce";
    if (-f $f) { open my $fh, '<', $f; my $n = <$fh>; chomp $n; return $n if $n; }
    die "无法获取 Replay-Nonce\n";
}
sub save_nonce {
    my ($hdr) = @_;
    $_nonce_cache = $1 if $hdr =~ /^Replay-Nonce:\s*(\S+)/im;
}

sub get_jwk {
    # extract RSA n/e from account key via openssl asn1parse
    my $der = `openssl rsa -pubout -in '$acct_key' -outform DER 2>/dev/null`;
    open my $fh, '|-', "openssl asn1parse -inform DER 2>/dev/null" or die;
    print $fh $der; close $fh;
    # Actually, pipe properly
    my $out = qx{echo '$der' | openssl asn1parse -inform DER 2>/dev/null};
    # Simpler: write temp file
    use File::Temp qw(tempfile);
    my ($tfh, $tfn) = tempfile();
    print $tfh $der; close $tfh;
    $out = `openssl asn1parse -inform DER -in '$tfn' 2>/dev/null`;
    unlink $tfn;

    my @ints;
    for my $line (split /\n/, $out) {
        if ($line =~ /INTEGER/ && $line =~ /:(\w+)$/) {
            push @ints, $1;
        }
    }
    die "Cannot parse key\n" unless @ints >= 2;
    my ($n_hex, $e_hex) = @ints[0, 1];
    $n_hex =~ s/^00//;
    return { kty => 'RSA', n => b64u(pack('H*', $n_hex)), e => b64u(pack('H*', $e_hex)) };
}

sub thumbprint {
    my $jwk = get_jwk();
    my $canon = JSON::PP->new->canonical(1)->encode($jwk);
    $canon =~ s/ //g;
    return b64u(sha256($canon));
}

sub _curl {
    my ($url, $data, $hdr_extra) = @_;
    my $hdr_file = "/tmp/acme_hdr_$$.txt";
    my $cmd = qq{curl -s -w '%{http_code}' -D '$hdr_file' -X POST '$url' -H 'Content-Type: application/jose+json' -d '$data' 2>/dev/null};
    my $out = `$cmd`;
    if (-f $hdr_file) {
        open my $fh, '<', $hdr_file; my $hdr = do { local $/; <$fh> }; close $fh;
        save_nonce($hdr);
        unlink $hdr_file;
    }
    my $code = substr($out, -3, 3, '');
    return ($code, $out);
}

sub acme_request {
    my ($url, $payload, $kid) = @_;
    $kid //= '';
    my $nonce = get_nonce();
    my $hdr = { alg => 'RS256', nonce => $nonce, url => $url };
    if ($kid) { $hdr->{kid} = $kid }
    else      { $hdr->{jwk} = get_jwk() }

    my $payload_str = $payload ? JSON::PP->new->encode($payload) : '';
    my $prot64 = b64u_str(JSON::PP->new->encode($hdr));
    my $payl64 = $payload_str ? b64u_str($payload_str) : '';
    my $si = "$prot64.$payl64";

    my $sig = `echo -n '$si' | openssl dgst -sha256 -sign '$acct_key' 2>/dev/null | openssl base64 -A`;
    chomp $sig; $sig =~ tr{+/}{-_}; $sig =~ s{=+$}{};

    my $jws = JSON::PP->new->encode({ protected => $prot64, payload => $payl64, signature => $sig });
    $jws =~ s/'/'\\''/g;

    my ($code, $body) = _curl($url, $jws);
    if ($code >= 400) {
        my $err = eval { JSON::PP->new->decode($body)->{detail} } // $body;
        die "ACME HTTP $code: $err\n";
    }
    return $body ? JSON::PP->new->decode($body) : {};
}

# ── ACME flow ──
sub obtain {
    my ($domain, $email) = @_;
    eval {
        # 0. directory
        my $dir = JSON::PP->new->decode(`curl -s '$DIR_URL'`);
        my ($newAcct, $newOrd) = ($dir->{newAccount}, $dir->{newOrder});

        # 1. account
        my $kid = '';
        my $kid_file = "$acct_dir/account_url.txt";
        if (-f $kid_file) {
            open my $fh, '<', $kid_file; $kid = <$fh>; chomp $kid;
            print "   复用账户: $kid\n";
        } else {
            my $pl = { termsOfServiceAgreed => JSON::PP::true, contact => ["mailto:$email"] };
            # For newAccount we need JWK, get kid from Location header
            my $nonce = get_nonce();
            my $jwk = get_jwk();
            my $hdr = JSON::PP->new->encode({ alg => 'RS256', nonce => $nonce, url => $newAcct, jwk => $jwk });
            my $payload = JSON::PP->new->encode($pl);
            my $prot64 = b64u_str($hdr); my $payl64 = b64u_str($payload);
            my $si = "$prot64.$payl64";
            my $sig = `echo -n '$si' | openssl dgst -sha256 -sign '$acct_key' 2>/dev/null | openssl base64 -A`;
            chomp $sig; $sig =~ tr{+/}{-_}; $sig =~ s{=+$}{};
            my $jws = JSON::PP->new->encode({ protected => $prot64, payload => $payl64, signature => $sig });
            $jws =~ s/'/'\\''/g;

            my $hdr_file = "/tmp/acme_hdr_$$.txt";
            my $resp_headers = `curl -s -D '$hdr_file' -o /dev/null -X POST '$newAcct' -H 'Content-Type: application/jose+json' -d '$jws' 2>/dev/null`;
            open my $fh, '<', $hdr_file; my $all = do { local $/; <$fh> }; close $fh;
            if ($all =~ m{^Location:\s*(\S+)}im) { $kid = $1; }
            save_nonce($all);
            unlink $hdr_file;
            die "无法获取账户 URL\n" unless $kid;

            mkpath($acct_dir);
            open $fh, '>', $kid_file; print $fh $kid; close $fh;
            open $fh, '>', "$acct_dir/email.txt"; print $fh $email; close $fh;
        }

        # 2. order
        my $order = acme_request($newOrd, { identifiers => [{ type => 'dns', value => $domain }] }, $kid);
        my ($fin_url, $ord_url) = ($order->{finalize}, $order->{url} || '');

        # 3. challenges
        mkpath($ch_dir);
        for my $auth_url (@{$order->{authorizations}}) {
            my $auth = acme_request($auth_url, '', $kid);
            my @ch = grep { $_->{type} eq 'http-01' } @{$auth->{challenges}};
            die "无 http-01 挑战\n" unless @ch;
            my ($token, $ch_url) = ($ch[0]->{token}, $ch[0]->{url});
            my $ka = "$token." . thumbprint();
            open my $fh, '>', "$ch_dir/$token"; print $fh $ka; close $fh;
            acme_request($ch_url, {}, $kid);
            for (1..12) {
                sleep 2;
                my $st = acme_request($auth_url, '', $kid)->{status};
                last if $st eq 'valid';
                die "验证失败\n" if $st eq 'invalid';
            }
            unlink "$ch_dir/$token";
        }

        # 4. CSR
        my $cert_key = "$data_dir/$domain/private.pem";
        mkpath("$data_dir/$domain");
        `openssl genrsa -out '$cert_key' 2048 2>/dev/null`;
        my $csr_der = `openssl req -new -sha256 -key '$cert_key' -subj '/CN=$domain' -addext 'subjectAltName=DNS:$domain' -outform DER 2>/dev/null`;
        acme_request($fin_url, { csr => b64u($csr_der) }, $kid);

        # 5. download
        for (1..15) {
            sleep 2;
            my $o = acme_request($ord_url, '', $kid);
            if (my $cert_url = $o->{certificate}) {
                my $pem = acme_request($cert_url, '', $kid);
                open my $fh, '>', "$data_dir/$domain/fullchain.pem";
                print $fh ref $pem ? JSON::PP->new->encode($pem) : $pem;
                close $fh;
                print "   [OK] $domain\n";
                return 1;
            }
        }
        die "签发超时\n";
    };
    if ($@) { print "   [FAIL] $@"; return 0; }
    return 1;
}

# ── main ──
my $now = localtime;
print $now->strftime('%Y-%m-%d %H:%M:%S') . " 证书续期检查 (Perl ACME)\n";
print "环境：" . ($staging ? "Staging" : "Production") . "\n";
print "** 试运行 **\n" if $dry_run;

if (!$email && -f "$acct_dir/email.txt") {
    open my $fh, '<', "$acct_dir/email.txt"; $email = <$fh>; chomp $email;
}
die "ERR: 需要 --email=x\@x.com\n" unless $email;

mkpath($acct_dir);
`openssl genrsa -out '$acct_key' 2048 2>/dev/null` unless -f $acct_key;

my ($renewed, $skipped, $errors) = (0, 0, 0);
opendir my $dh, $data_dir or die $!;
for my $entry (sort grep { !/^\.\.?$/ && !/^account$/ } readdir $dh) {
    my $cert = "$data_dir/$entry/fullchain.pem";
    next unless -f $cert;

    my $ed = `openssl x509 -in '$cert' -noout -enddate 2>/dev/null`;
    next unless $ed =~ /notAfter=(.+)/;
    my $end = Time::Piece->strptime($1, '%b %d %H:%M:%S %Y %Z');
    my $days = int(($end - localtime) / ONE_DAY);
    $days = 0 if $days < 0;
    print "\n$entry (剩余${days}天)\n";

    if (!$force && $days > 30) { $skipped++; next; }
    if ($dry_run)             { $renewed++; next; }

    obtain($entry, $email) ? $renewed++ : $errors++;
}
closedir $dh;

print "\n" . localtime->strftime('%Y-%m-%d %H:%M:%S') . " 续期:$renewed 跳过:$skipped 失败:$errors\n";
exit($errors ? 1 : 0);
