Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d078fb0
fix(site): keep the proxy config valid when a proxy cache update fails
mrrobot47 Sep 29, 2026
6060570
fix(site): roll back an alias change when its certificate can't be is…
mrrobot47 Sep 29, 2026
1108099
fix(site): remove a site's certificate files whenever they exist
mrrobot47 Sep 29, 2026
26b45d9
fix(site): keep sites without SSL HTTP-only on alias changes
mrrobot47 Sep 29, 2026
773af89
fix(site): skip ACME challenges EE can't solve when ordering a certif…
mrrobot47 Sep 29, 2026
4d2da2e
fix(site): parse certificates that have no subject CN
mrrobot47 Sep 29, 2026
b795f55
fix(site): allow turning SSL off on sites stored as wildcard
mrrobot47 Sep 29, 2026
96fade4
fix(site): reload the proxy after removing a deleted site's www redirect
mrrobot47 Sep 29, 2026
b052b80
fix(site): fail `update --ssl=le` when no certificate was issued
mrrobot47 Sep 29, 2026
1fea072
fix(site): keep token-bearing ACME challenges of every type
mrrobot47 Sep 29, 2026
9b5aa02
fix(site): revoke only certificates an alias change replaced
mrrobot47 Sep 29, 2026
46e8ef6
fix(site): refuse `--ssl=off` while other sites inherit the certificate
mrrobot47 Sep 29, 2026
3f1759e
fix(site): put the site back HTTP-only when `update --ssl=` throws
mrrobot47 Sep 29, 2026
e33671a
fix(site): remove a deleted alias's ACME state
mrrobot47 Sep 29, 2026
809e092
refactor(site): drop the unvalidated le-mail fallback from the alias …
mrrobot47 Sep 29, 2026
8b3daa7
fix(site): keep a custom certificate the user keeps in the certs dir …
mrrobot47 Sep 29, 2026
aa4766d
fix(site): let a self-signed site enable SSL again after `--ssl=off`
mrrobot47 Sep 29, 2026
09217cb
fix(site): name every needed flag in the `update --ssl=` re-run hint
mrrobot47 Sep 29, 2026
a6074e6
fix(site): remove a wildcard certificate's `*.<site>` ACME state with…
mrrobot47 Sep 29, 2026
b5128d3
docs(site): say which files a failed certificate reissue puts back
mrrobot47 Sep 29, 2026
8c1ed2a
fix(site): refuse normal SSL on a wildcard site only when it needs th…
mrrobot47 Sep 29, 2026
a9372b4
fix(site): stop before changing files when a backup can't be read
mrrobot47 Sep 29, 2026
5c34be8
fix(site): treat self-signed SSL as wildcard when updating a site
mrrobot47 Sep 29, 2026
3df337e
fix(site): restore the proxy cache files when writing them fails
mrrobot47 Sep 29, 2026
4d549a5
fix(site): show the first SAN as the subject in ssl-info when there i…
mrrobot47 Sep 29, 2026
0fbab4d
fix(site): quote the certificate paths in the update --ssl=custom re-…
mrrobot47 Sep 29, 2026
38c68ff
fix(site): don't restart the proxy when the reload refused the new co…
mrrobot47 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 101 additions & 3 deletions src/helper/Site_Letsencrypt.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@
use AcmePhp\Core\Exception\Protocol\CertificateRevocationException;
use AcmePhp\Core\Exception\Server\RateLimitedServerException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use AcmePhp\Core\Protocol\CertificateOrder;
use AcmePhp\Core\Protocol\ResourcesDirectory;
use AcmePhp\Core\Protocol\RevocationReason;
use AcmePhp\Core\Http\Base64SafeEncoder;
use AcmePhp\Core\Http\SecureHttpClient;
use AcmePhp\Core\Http\ServerErrorHandler;
use AcmePhp\Ssl\Certificate;
use AcmePhp\Ssl\CertificateRequest;
use AcmePhp\Ssl\DistinguishedName;
use AcmePhp\Ssl\Generator\KeyPairGenerator;
use AcmePhp\Ssl\ParsedCertificate;
use AcmePhp\Ssl\Parser\CertificateParser;
use AcmePhp\Ssl\Parser\KeyParser;
use AcmePhp\Ssl\Signer\CertificateRequestSigner;
Expand Down Expand Up @@ -70,6 +73,65 @@ private function getResourceAccount()
return $this->account;
}

/**
* Same as acmephp's requestOrder(), but skips challenges it can't represent.
*
* acmephp 1.3 reads a token from every challenge, so one without a token (the draft dns-persist-01 that Pebble 2.10 offers) made the whole order throw. Other types are kept as before: authorize() picks the one its solver supports, or a valid one.
*
* @param array $domains Domains to order a certificate for.
*
* @return CertificateOrder
*/
public function requestOrder( array $domains ) {
\Webmozart\Assert\Assert::allStringNotEmpty( $domains, 'requestOrder::$domains expected a list of strings. Got: %s' );

$payload = [
'identifiers' => array_map(
function ( $domain ) {
return [
'type' => 'dns',
'value' => $domain,
];
},
array_values( $domains )
),
];

$client = $this->getHttpClient();
$resourceUrl = $this->getResourceUrl( ResourcesDirectory::NEW_ORDER );
$response = $client->request( 'POST', $resourceUrl, $client->signKidPayload( $resourceUrl, $this->getResourceAccount(), $payload ) );
if ( ! isset( $response['authorizations'] ) || ! $response['authorizations'] ) {
throw new ChallengeNotSupportedException();
}

$orderEndpoint = $client->getLastLocation();
$authorizationsChallenges = [];
$base64encoder = $client->getBase64Encoder();
foreach ( $response['authorizations'] as $authorizationEndpoint ) {
$authorizationsResponse = $client->request( 'POST', $authorizationEndpoint, $client->signKidPayload( $authorizationEndpoint, $this->getResourceAccount(), null ) );
$domain = ( empty( $authorizationsResponse['wildcard'] ) ? '' : '*.' ) . $authorizationsResponse['identifier']['value'];

// An empty list still reaches authorize(), which reports the domain as unsupported.
$authorizationsChallenges[ $domain ] = [];
foreach ( $authorizationsResponse['challenges'] as $challenge ) {
if ( empty( $challenge['token'] ) || ! is_string( $challenge['token'] ) || ! isset( $challenge['type'], $challenge['status'], $challenge['url'] ) ) {
\EE::debug( 'Skipping ACME challenge without a token: ' . ( $challenge['type'] ?? '(no type)' ) . ' for ' . $domain );
continue;
}
$authorizationsChallenges[ $domain ][] = new AuthorizationChallenge(
$authorizationsResponse['identifier']['value'],
$challenge['status'],
$challenge['type'],
$challenge['url'],
$challenge['token'],
$challenge['token'] . '.' . $base64encoder->encode( $client->getJWKThumbprint() )
);
}
}

return new CertificateOrder( $authorizationsChallenges, $orderEndpoint );
}

public function revokeAuthorizationChallenge(AuthorizationChallenge $challenge)
{
$payload = [
Expand Down Expand Up @@ -97,6 +159,42 @@ public function revokeAuthorizationChallenge(AuthorizationChallenge $challenge)
}


/**
* acmephp's parser requires a subject CN, which certificates from LE's newer profiles (and Pebble's default one) don't have.
*/
class EECertificateParser extends CertificateParser {

public function parse( Certificate $certificate ) {
$rawData = openssl_x509_parse( $certificate->getPEM() );

if ( ! is_array( $rawData ) || isset( $rawData['subject']['CN'] ) || ! isset( $rawData['extensions']['subjectAltName'], $rawData['serialNumber'], $rawData['validFrom_time_t'], $rawData['validTo_time_t'] ) ) {
return parent::parse( $certificate );
}

$san = [];
foreach ( explode( ',', $rawData['extensions']['subjectAltName'] ) as $item ) {
if ( false !== strpos( $item, ':' ) ) {
$san[] = explode( ':', trim( $item ), 2 )[1];
}
}
if ( empty( $san ) ) {
return parent::parse( $certificate );
}

// Use the first SAN as the subject, as LE's classic profile does.
return new ParsedCertificate(
$certificate,
$san[0],
isset( $rawData['issuer']['CN'] ) ? $rawData['issuer']['CN'] : null,
$rawData['subject'] === $rawData['issuer'],
new \DateTime( '@' . $rawData['validFrom_time_t'] ),
new \DateTime( '@' . $rawData['validTo_time_t'] ),
$rawData['serialNumber'],
$san
);
}
}

class Site_Letsencrypt {

private $accountKeyPair;
Expand Down Expand Up @@ -668,7 +766,7 @@ public function isAlreadyExpired( $domain ) {
\EE::log( "Loading current certificate for $domain" );

$certificate = $this->repository->loadDomainCertificate( $domain );
$certificateParser = new CertificateParser();
$certificateParser = new EECertificateParser();
$parsedCertificate = $certificateParser->parse( $certificate );

if ( $parsedCertificate->getValidTo()->format( 'U' ) - time() < 0 ) {
Expand Down Expand Up @@ -699,7 +797,7 @@ public function isRenewalNecessary( $domain ) {
\EE::log( "Loading current certificate for $domain" );

$certificate = $this->repository->loadDomainCertificate( $domain );
$certificateParser = new CertificateParser();
$certificateParser = new EECertificateParser();
$parsedCertificate = $certificateParser->parse( $certificate );

// 3024000 = 35 days.
Expand Down Expand Up @@ -749,7 +847,7 @@ private function executeRenewal( $domain, array $alternativeNames, $email, $forc
$certificate = $this->repository->loadDomainCertificate( $domain );

if ( ! $force ) {
$certificateParser = new CertificateParser();
$certificateParser = new EECertificateParser();
$parsedCertificate = $certificateParser->parse( $certificate );

// 3024000 = 35 days.
Expand Down
Loading
Loading