Skip to content

Commit 1703db6

Browse files
committed
Merge branch 'develop' for v3.6.0
2 parents be13167 + ec1f7b2 commit 1703db6

File tree

4 files changed

+354
-16
lines changed

4 files changed

+354
-16
lines changed
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
<?php
2+
3+
namespace EE\Migration;
4+
5+
use EE;
6+
use EE\Model\Site;
7+
use EE\Migration\Base;
8+
9+
class FixSslFlagForExistingLeCerts extends Base {
10+
public function __construct() {
11+
parent::__construct();
12+
$this->sites = Site::all();
13+
if ( $this->is_first_execution || ! $this->sites ) {
14+
$this->skip_this_migration = true;
15+
}
16+
}
17+
18+
public function up() {
19+
if ( $this->skip_this_migration ) {
20+
EE::debug( 'Skipping fix_ssl_flag_for_existing_le_certs migration as it is not needed.' );
21+
22+
return;
23+
}
24+
$certs_dir = EE_ROOT_DIR . '/services/nginx-proxy/certs/';
25+
$conf_dir = EE_ROOT_DIR . '/services/nginx-proxy/conf.d/';
26+
$backup_dir = defined( 'EE_BACKUP_DIR' ) ? EE_BACKUP_DIR : EE_ROOT_DIR . '/.backup';
27+
if ( ! is_dir( $backup_dir ) ) {
28+
@mkdir( $backup_dir, 0755, true );
29+
}
30+
$log_file = $backup_dir . '/.ssl-fix.log';
31+
$log_entries = [];
32+
foreach ( $this->sites as $site ) {
33+
$site_url = $site->site_url;
34+
$crt = $certs_dir . $site_url . '.crt';
35+
$key = $certs_dir . $site_url . '.key';
36+
$chain = $certs_dir . $site_url . '.chain.pem';
37+
$redirect_conf = $conf_dir . $site_url . '-redirect.conf';
38+
39+
$crt_exists = file_exists( $crt );
40+
$key_exists = file_exists( $key );
41+
$chain_exists = file_exists( $chain );
42+
$db_ssl = $site->site_ssl;
43+
$actions = [];
44+
45+
// If redirect conf exists but no certs, remove conf and reload nginx proxy
46+
if ( file_exists( $redirect_conf ) && ( ! $crt_exists || ! $key_exists || ! $chain_exists ) ) {
47+
EE::log( "Removing orphan redirect conf for $site_url and reloading nginx proxy." );
48+
@unlink( $redirect_conf );
49+
$actions[] = "Removed redirect conf: $redirect_conf";
50+
\EE\Site\Utils\reload_global_nginx_proxy();
51+
}
52+
53+
if ( $crt_exists && $key_exists && $chain_exists ) {
54+
if ( empty( $db_ssl ) || $db_ssl !== 'le' ) {
55+
// Check if the cert is a valid Let's Encrypt cert using CertificateParser
56+
try {
57+
$crt_pem = file_get_contents( $crt );
58+
if ( ! function_exists( 'openssl_x509_parse' ) ) {
59+
EE::warning( "openssl_x509_parse() not available in PHP. Cannot check issuer for $site_url." );
60+
$actions[] = "openssl_x509_parse() not available, skipping Let's Encrypt detection";
61+
} else {
62+
$cert_data = openssl_x509_parse( $crt_pem );
63+
$issuer_full = isset( $cert_data['issuer'] ) ? $cert_data['issuer'] : [];
64+
$issuer_json = json_encode( $issuer_full );
65+
$subject_cn = isset( $cert_data['subject']['CN'] ) ? $cert_data['subject']['CN'] : '';
66+
$crt_pem_lines = implode( ' | ', array_slice( explode( "\n", $crt_pem ), 0, 2 ) );
67+
$actions[] = "Cert issuer: $issuer_json";
68+
$actions[] = "Cert subject CN: '$subject_cn'";
69+
$actions[] = "Cert PEM first lines: $crt_pem_lines";
70+
71+
// Check all issuer fields for 'Let's Encrypt'
72+
$le_found = false;
73+
foreach ( $issuer_full as $field => $value ) {
74+
if ( stripos( $value, "Let's Encrypt" ) !== false ) {
75+
$le_found = true;
76+
break;
77+
}
78+
}
79+
if ( $le_found ) {
80+
EE::log( "Updating SSL flag for site $site_url: found valid Let's Encrypt cert." );
81+
$site->site_ssl = 'le';
82+
$site->save();
83+
$actions[] = "Updated DB: set site_ssl=le (valid LE cert)";
84+
} else {
85+
$actions[] = "Cert is not from Let's Encrypt, no DB update";
86+
}
87+
}
88+
} catch ( \Exception $e ) {
89+
EE::debug( "Failed to parse certificate for $site_url: " . $e->getMessage() );
90+
$actions[] = "Failed to parse certificate: " . $e->getMessage();
91+
}
92+
}
93+
}
94+
95+
if ( empty( $actions ) ) {
96+
$actions[] = 'No action needed';
97+
}
98+
$log_entries[] = sprintf(
99+
"%s [%s] DB: '%s', crt: %s, key: %s, chain: %s -- %s",
100+
date( 'c' ),
101+
$site_url,
102+
$db_ssl === null ? '' : $db_ssl,
103+
$crt_exists ? 'yes' : 'no',
104+
$key_exists ? 'yes' : 'no',
105+
$chain_exists ? 'yes' : 'no',
106+
implode( '; ', $actions )
107+
);
108+
}
109+
if ( $log_entries ) {
110+
file_put_contents( $log_file, implode( "\n", $log_entries ) . "\n", FILE_APPEND );
111+
}
112+
}
113+
114+
public function down() {
115+
// No-op: This migration is not reversible.
116+
}
117+
}

src/helper/Site_Letsencrypt.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,8 @@ private function executeFirstRequest( $domain, array $alternativeNames, $email )
488488

489489
// Post-generate actions
490490
$this->moveCertsToNginxProxy( $domain );
491+
492+
return true;
491493
}
492494

493495
private function moveCertsToNginxProxy( string $domain ) {
@@ -523,7 +525,7 @@ public function isAlreadyExpired( $domain ) {
523525
if ( $parsedCertificate->getValidTo()->format( 'U' ) - time() < 0 ) {
524526
\EE::log(
525527
sprintf(
526-
'Current certificate is alerady expired on %s, renewal is necessary.',
528+
'Current certificate is already expired on %s, renewal is necessary.',
527529
$parsedCertificate->getValidTo()->format( 'Y-m-d H:i:s' )
528530
)
529531
);
@@ -636,6 +638,8 @@ private function executeRenewal( $domain, array $alternativeNames, $force = fals
636638
$this->moveCertsToNginxProxy( $domain );
637639
\EE::log( 'Certificate renewed successfully!' );
638640

641+
return true;
642+
639643
} catch ( \Exception $e ) {
640644
\EE::warning( 'A critical error occured during certificate renewal' );
641645
\EE::debug( print_r( $e, true ) );
@@ -734,5 +738,26 @@ public function cleanup() {
734738
$fs->remove( $challange_dir );
735739
}
736740
}
737-
}
738741

742+
/**
743+
* Check if a domain has a stored ACME authorization challenge.
744+
*
745+
* @param string $domain The domain to check for a stored challenge.
746+
*
747+
* @return bool True if a challenge exists for the domain, false otherwise.
748+
*/
749+
public function hasDomainAuthorizationChallenge( $domain ) {
750+
return $this->repository->hasDomainAuthorizationChallenge( $domain );
751+
}
752+
753+
/**
754+
* Load the stored ACME authorization challenge for a domain.
755+
*
756+
* @param string $domain The domain to load the challenge for.
757+
*
758+
* @return AuthorizationChallenge|null The challenge object, or null if not found.
759+
*/
760+
public function loadDomainAuthorizationChallenge( $domain ) {
761+
return $this->repository->loadDomainAuthorizationChallenge( $domain );
762+
}
763+
}

src/helper/class-ee-site.php

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1719,6 +1719,193 @@ public function ssl_verify( $args = [], $assoc_args = [], $www_or_non_www = fals
17191719
return true;
17201720
}
17211721

1722+
/**
1723+
* Shows SSL info and DNS challenge records for a site.
1724+
*
1725+
* ## OPTIONS
1726+
*
1727+
* [<site-name>]
1728+
* : Name of website.
1729+
*
1730+
* [--get-dns-records]
1731+
* : Show DNS challenge records (if using DNS-01 challenge).
1732+
*
1733+
* [--format=<format>]
1734+
* : Render output in a particular format.
1735+
* ---
1736+
* default: table
1737+
* options:
1738+
* - table
1739+
* - csv
1740+
* - yaml
1741+
* - json
1742+
* ---
1743+
*
1744+
* ## EXAMPLES
1745+
*
1746+
* # Show SSL info for a site
1747+
* $ ee site ssl-info example.com
1748+
*
1749+
* # Show DNS challenge info for a site
1750+
* $ ee site ssl-info example.com --get-dns-records
1751+
*
1752+
* @subcommand ssl-info
1753+
*/
1754+
public function ssl_info( $args, $assoc_args ) {
1755+
$args = auto_site_name( $args, 'site', __FUNCTION__ );
1756+
$this->site_data = get_site_info( $args, false, true, false );
1757+
1758+
$site_url = $this->site_data->site_url;
1759+
$wildcard = ! empty( $this->site_data->site_ssl_wildcard );
1760+
$alias_domains = empty( $this->site_data->alias_domains ) ? [] : explode( ',', $this->site_data->alias_domains );
1761+
$domains = $this->get_cert_domains( $site_url, $wildcard );
1762+
$domains = array_unique( array_merge( $domains, $alias_domains ) );
1763+
1764+
$output = [];
1765+
$warnings = [];
1766+
1767+
// If --get-dns-records is passed, show DNS challenge info (old behavior)
1768+
if ( \EE\Utils\get_flag_value( $assoc_args, 'get-dns-records', false ) ) {
1769+
$preferred_challenge = get_preferred_ssl_challenge( $domains );
1770+
$is_dns = $wildcard || $preferred_challenge === 'dns';
1771+
1772+
if ( ! $is_dns ) {
1773+
$warnings[] = 'This site does not use DNS-based (DNS-01) SSL challenge.';
1774+
} else {
1775+
$client = new \EE\Site\Type\Site_Letsencrypt();
1776+
$rows = [];
1777+
foreach ( $domains as $domain ) {
1778+
if ( $client->hasDomainAuthorizationChallenge( $domain ) ) {
1779+
$challenge = $client->loadDomainAuthorizationChallenge( $domain );
1780+
if ( method_exists( $challenge, 'toArray' ) ) {
1781+
$data = $challenge->toArray();
1782+
$record_name = isset( $data['dnsRecordName'] ) ? $data['dnsRecordName'] : '_acme-challenge.' . $domain;
1783+
if ( isset( $data['dnsRecordValue'] ) ) {
1784+
$record_value = $data['dnsRecordValue'];
1785+
} elseif ( isset( $data['payload'] ) ) {
1786+
$keyAuthorization = $data['payload'];
1787+
$digest = rtrim( strtr( base64_encode( hash( 'sha256', $keyAuthorization, true ) ), '+/', '-_' ), '=' );
1788+
$record_value = $digest;
1789+
} else {
1790+
$record_value = '';
1791+
}
1792+
$rows[] = [
1793+
'domain' => $domain,
1794+
'record_name' => $record_name,
1795+
'record_value' => $record_value,
1796+
];
1797+
} else {
1798+
$warnings[] = "Could not extract DNS challenge for $domain.";
1799+
}
1800+
} else {
1801+
$warnings[] = "No pending DNS challenge found for $domain. (Try running 'ee site ssl-verify $site_url' if you are setting up SSL)";
1802+
}
1803+
}
1804+
$output['dns_challenges'] = $rows;
1805+
}
1806+
$output['warnings'] = $warnings;
1807+
$formatter = new \EE\Formatter( $assoc_args, array_keys( $output ) );
1808+
$formatter->display_items( [ $output ] );
1809+
1810+
return;
1811+
}
1812+
1813+
// Otherwise, show SSL status and cert details
1814+
$ssl_type = $this->site_data->site_ssl;
1815+
$output['ssl_type'] = $ssl_type ? $ssl_type : 'off';
1816+
1817+
if ( ! $ssl_type || $ssl_type === 'off' ) {
1818+
$output['status'] = 'SSL is not enabled for this site.';
1819+
$output['warnings'] = $warnings;
1820+
$formatter = new \EE\Formatter( $assoc_args, array_keys( $output ) );
1821+
$formatter->display_items( [ $output ] );
1822+
1823+
return;
1824+
}
1825+
1826+
// Determine which cert to show (le, self, inherit, custom)
1827+
$cert_site_name = $site_url;
1828+
if ( $ssl_type === 'inherit' ) {
1829+
$cert_site_name = implode( '.', array_slice( explode( '.', $site_url ), 1 ) );
1830+
}
1831+
1832+
$certs_dir = EE_ROOT_DIR . '/services/nginx-proxy/certs/';
1833+
$crt_file = $certs_dir . $cert_site_name . '.crt';
1834+
1835+
if ( ! file_exists( $crt_file ) ) {
1836+
$warnings[] = "Certificate file not found for $cert_site_name ($crt_file)";
1837+
$output['status'] = 'Certificate file not found / yet to be issued.';
1838+
$output['warnings'] = $warnings;
1839+
$formatter = new \EE\Formatter( $assoc_args, array_keys( $output ) );
1840+
$formatter->display_items( [ $output ] );
1841+
1842+
return;
1843+
}
1844+
1845+
try {
1846+
$certificate = new \AcmePhp\Ssl\Certificate( file_get_contents( $crt_file ) );
1847+
$certificateParser = new \AcmePhp\Ssl\Parser\CertificateParser();
1848+
$parsedCertificate = $certificateParser->parse( $certificate );
1849+
1850+
$issuer = $parsedCertificate->getIssuer();
1851+
$subject = $parsedCertificate->getSubject();
1852+
$validFrom = $parsedCertificate->getValidFrom()->format( 'Y-m-d H:i:s' );
1853+
$validTo = $parsedCertificate->getValidTo()->format( 'Y-m-d H:i:s' );
1854+
$serial = $parsedCertificate->getSerialNumber();
1855+
1856+
// Use openssl_x509_parse for CN fields, as in migration
1857+
$crt_pem = file_get_contents( $crt_file );
1858+
if ( function_exists( 'openssl_x509_parse' ) ) {
1859+
$cert_data = openssl_x509_parse( $crt_pem );
1860+
$subjectCN = isset( $cert_data['subject']['CN'] ) ? $cert_data['subject']['CN'] : '';
1861+
$issuer_full = isset( $cert_data['issuer'] ) ? $cert_data['issuer'] : [];
1862+
$le_found = false;
1863+
foreach ( $issuer_full as $field => $value ) {
1864+
if ( stripos( $value, "Let's Encrypt" ) !== false ) {
1865+
$le_found = true;
1866+
break;
1867+
}
1868+
}
1869+
if ( $le_found ) {
1870+
$issuerCN = "Let's Encrypt";
1871+
} else {
1872+
$issuerCN = isset( $issuer_full['CN'] ) ? $issuer_full['CN'] : implode( ', ', $issuer_full );
1873+
}
1874+
} else {
1875+
if ( is_object( $subject ) && method_exists( $subject, 'getField' ) ) {
1876+
$subjectCN = $subject->getField( 'CN' );
1877+
} else {
1878+
$subjectCN = is_string( $subject ) ? $subject : json_encode( $subject );
1879+
$warnings[] = 'Could not parse subject CN: unexpected type.';
1880+
}
1881+
if ( is_object( $issuer ) && method_exists( $issuer, 'getField' ) ) {
1882+
$issuerCN = $issuer->getField( 'CN' );
1883+
} else {
1884+
$issuerCN = is_string( $issuer ) ? $issuer : json_encode( $issuer );
1885+
$warnings[] = 'Could not parse issuer CN: unexpected type.';
1886+
}
1887+
$warnings[] = 'openssl_x509_parse() not available in PHP. Used fallback parser.';
1888+
}
1889+
$san = $parsedCertificate->getSubjectAlternativeNames();
1890+
1891+
$output['cert_file'] = $crt_file;
1892+
$output['issued_to_CN'] = $subjectCN;
1893+
$output['issued_by_CN'] = $issuerCN;
1894+
$output['valid_from'] = $validFrom;
1895+
$output['valid_till'] = $validTo;
1896+
$output['serial_number'] = $serial;
1897+
$output['SANs'] = implode( ', ', $san );
1898+
$output['status'] = 'SSL certificate details loaded.';
1899+
} catch ( \Exception $e ) {
1900+
$warnings[] = 'Could not parse certificate: ' . $e->getMessage();
1901+
$output['status'] = 'Could not parse certificate.';
1902+
}
1903+
$output['warnings'] = $warnings;
1904+
1905+
$formatter = new \EE\Formatter( $assoc_args, array_keys( $output ) );
1906+
$formatter->display_items( [ $output ] );
1907+
}
1908+
17221909
/**
17231910
* Renews letsencrypt ssl certificates.
17241911
*

0 commit comments

Comments
 (0)