1: <?php
2: declare(strict_types=1);
3: /**
4: * +------------------------------------------------------------+
5: * | apnscp |
6: * +------------------------------------------------------------+
7: * | Copyright (c) Apis Networks |
8: * +------------------------------------------------------------+
9: * | Licensed under Artistic License 2.0 |
10: * +------------------------------------------------------------+
11: * | Author: Matt Saladna (msaladna@apisnetworks.com) |
12: * +------------------------------------------------------------+
13: */
14:
15: use AcmePhp\Core\Protocol\AuthorizationChallenge;
16: use Module\Support\Letsencrypt;
17: use Opcenter\Crypto\Letsencrypt as LEService;
18: use Opcenter\Crypto\Letsencrypt as LetsencryptAlias;
19:
20: /**
21: * Let's Encrypt integration utilities
22: *
23: * @author Matt Saladna <matt@apisnetworks.com>
24: */
25: class Letsencrypt_Module extends Letsencrypt implements \Module\Skeleton\Contracts\Hookable, \Module\Skeleton\Contracts\Reactive
26: {
27: const DEPENDENCY_MAP = [
28: 'ssl'
29: ];
30: // @var int max number of SSL bootstrap attempts
31: const BOOTSTRAP_ATTEMPTS = LETSENCRYPT_BOOTSTRAP_ATTEMPTS;
32: // production
33: const LETSENCRYPT_SERVER = 'acme-v02.api.letsencrypt.org/directory';
34: // staging
35: const LETSENCRYPT_TESTING_SERVER = 'acme-staging-v02.api.letsencrypt.org/directory';
36: protected const LE_AUTHORITY_FINGERPRINT = LETSENCRYPT_KEYID;
37: protected const LE_STAGING_AUTHORITY_FINGERPRINT = LETSENCRYPT_STAGING_KEYID;
38: // include complementary hostname variant, e.g. foo.com + www.foo.com or www.foo.com + foo.com
39: const INCLUDE_ALT_FORM = LETSENCRYPT_ALTERNATIVE_FORM;
40: protected $activeServer;
41:
42: /**
43: * {{{ void __construct(void)
44: *
45: * @ignore
46: */
47:
48: public function __construct()
49: {
50: parent::__construct();
51:
52: $this->activeServer = \Opcenter\Crypto\Letsencrypt::activeServer();
53: if ($this->supported()) {
54: $fns = array(
55: '*' => PRIVILEGE_SITE,
56: 'request' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
57: 'append' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
58: 'renew_expiring' => PRIVILEGE_ADMIN,
59: 'revoke' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
60: 'challenges' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
61: 'solve' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
62: 'renew' => PRIVILEGE_SITE|PRIVILEGE_ADMIN
63: );
64: } else {
65: $fns = array(
66: 'supported' => PRIVILEGE_SITE,
67: 'permitted' => PRIVILEGE_SITE,
68: 'is_ca' => PRIVILEGE_SITE,
69: '*' => PRIVILEGE_NONE
70: );
71: }
72: $this->exportedFunctions = $fns;
73: }
74:
75: /**
76: * Let's Encrypt is supported on this platform
77: *
78: * @return bool
79: */
80: public function supported()
81: {
82: return true;
83: }
84:
85: /**
86: * Client may generate a LE certificate
87: *
88: * @return bool
89: */
90: public function permitted()
91: {
92: return $this->supported() && $this->ssl_permitted();
93: }
94:
95: /**
96: * Let's Encrypt issues bogus certificates
97: *
98: * @return bool
99: */
100: public function debug(): bool
101: {
102: return (bool)LETSENCRYPT_DEBUG;
103: }
104:
105: /**
106: * Renew Let's Encrypt SSL
107: *
108: * @param bool|null $verifyip perform IP verification prior to issuance
109: * @return bool
110: */
111: public function renew(bool $verifyip = null)
112: {
113: if ($this->permission_level & PRIVILEGE_SITE && $this->auth_is_inactive()) {
114: return error("account `%s' is inactive - not renewing SSL", $this->domain);
115: }
116:
117: if (null === ($cns = $this->getNonOrphanedDomainsFromCertificate())) {
118: return warn("Certificate is not Let's Encrypt");
119: }
120: if ($cns === []) {
121: return error('no certificates installed on account');
122: }
123: if ($cns === null) {
124: return warn("certificate for `%s' is not provided by LE", $this->domain);
125: }
126: $ret = $this->request($cns, $verifyip);
127: if (null === $ret) {
128: // case in which a request is processed OK, but
129: // there are no valid hostnames on the account to renew
130: return warn('request failed, lack of valid hostnames to renew');
131: }
132: if (!$ret) {
133: return error('failed to renew certificate');
134: }
135:
136: return info('successfully renewed certificate for 90 days');
137: }
138:
139: /**
140: * Renew all expiring certificates
141: *
142: * @return void
143: */
144: public function renew_expiring(): void {
145: $this->renewExpiringCertificates();
146:
147: if (!$this->systemNeedsIssuance()) {
148: return;
149: }
150:
151: // issue system certificate on bootstrap or hostname change
152: $cns = \Opcenter\Crypto\Ssl::generateHostnames($this->getAuthContext(), true);
153: if ($this->requestReal($cns, LetsencryptAlias::SYSCERT_NAME)) {
154: $this->installSystemCertificate();
155: }
156: }
157:
158: /**
159: * Get ACME challenges associated with hostnames
160: *
161: * "payload" must be converted to sha256 hash when entering as a TXT record
162: *
163: * @param string|array $hostnames
164: * @return array
165: */
166: public function challenges($hostnames): array
167: {
168: $hostnames = (array)$hostnames;
169: $sans = [];
170: foreach ($hostnames as $host) {
171: $chk = $host;
172: if (0 === strncmp($host, '*.', 2)) {
173: $chk = substr($chk, 2);
174: }
175: if (($this->permission_level & PRIVILEGE_SITE) && !$this->web_split_host($chk)) {
176: error("Invalid hostname `%s'", $chk);
177: continue;
178: }
179: $sans[$host] = null;
180: }
181: $dispatch = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$this->site]);
182: if ( !($challenges = $dispatch->challenges(array_keys($sans))) ) {
183: // exception generated
184: return [];
185: }
186: foreach (array_get($challenges->toArray(), 'authorizationsChallenges', []) as $domain => $challengeTypes) {
187: $sans[$domain] = array_map(static function (AuthorizationChallenge $c) {
188: return $c->toArray();
189: }, $challengeTypes);
190: }
191:
192: return $sans;
193: }
194:
195: /**
196: * Solve pending challenges
197: *
198: * @param string|array $hostname array of hostnames or map of hostname: solver
199: * @param string|null $solver type of "http", "dns", or "alpn"
200: * @return array
201: * @throws ArgumentError
202: */
203: public function solve($hostname, string $solver = null): bool
204: {
205: $dispatch = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$this->site]);
206: $dispatch->setStagingOnly(true);
207: if ($solver) {
208: array_fill_keys((array)$hostname, $solver);
209: } else if (!\is_array($hostname) || isset($hostname[0])) {
210: return error('$hostname parameter is not a map of hostnames => solvers');
211: }
212: $order = $dispatch->challenges((array)array_keys($hostname));
213:
214: $challengeSet = $order->getAuthorizationsChallenges();
215: foreach ($challengeSet as $host => &$challenges) {
216: foreach ($challenges as &$challenge) {
217: if (!isset($hostname[$host]) || 0 !== strpos($challenge->getType(), $hostname[$host])) {
218: $challenge = null;
219: continue;
220: }
221: }
222: unset($challenge);
223: $challenges = array_filter($challenges);
224: }
225: unset($challenges);
226: $revised = new \AcmePhp\Core\Protocol\CertificateOrder($challengeSet);
227:
228: return null === $dispatch->solve($revised);
229: }
230:
231: /**
232: * Set authorization mechanism
233: *
234: * @param string $domain
235: * @param string|null $mechanism mechanism to use or null to unset
236: * @return bool
237: */
238: public function use_mechanism(string $domain, ?string $mechanism): bool
239: {
240: if (!preg_match(Regex::DOMAIN_WC, $domain)) {
241: return error("Invalid hostname `%s'", $domain);
242: }
243: $prefs = \Preferences::factory($this->getAuthContext());
244: $mechanisms = array_get($prefs, LetsencryptAlias\Preferences::MECHANISM_PREFERENCE, []);
245: if (null === $mechanism) {
246: unset($prefs[$domain]);
247: }
248: if (!in_array($mechanism, LetsencryptAlias\AcmeDispatcher::VALIDATION_MODES, true)) {
249: return error("Unknown mechanism `%s'", $mechanism);
250: }
251: $mechanisms[$domain] = $mechanism;
252: array_set($prefs, LetsencryptAlias\Preferences::MECHANISM_PREFERENCE, $mechanisms);
253: return true;
254: }
255:
256: /**
257: * Get authorization mechanism for hostname
258: *
259: * @param string|null $domain optional domain to filter
260: * @return array|null|string all configured mechanisms
261: */
262: public function mechanism(?string $domain)
263: {
264: $prefs = array_get(
265: \Preferences::factory($this->getAuthContext()),
266: LetsencryptAlias\Preferences::MECHANISM_PREFERENCE,
267: []
268: );
269: if (null === $domain) {
270: return $prefs;
271: }
272: return $prefs[$domain] ?? null;
273: }
274:
275: /**
276: * Request a Let's Encrypt certificate for the given common names
277: *
278: * Because there is no unreasonable limit on SANs, a www and non-www
279: * variant for each CN will be generated
280: *
281: * @param array|string $cnames list of hosts
282: * @param bool $verifyip verify IP matches account before issuing
283: * @param bool $strict loss of any hostname from certificate causes operation to fail
284: * @return bool|null
285: */
286: public function request($cnames, ?bool $verifyip = null, ?bool $strict = null): ?bool
287: {
288: // allow CLI debugging, job runner drops only effective uid
289: if (posix_geteuid() && !IS_CLI) {
290: return $this->query('letsencrypt_request', $cnames, $verifyip, $strict);
291: }
292:
293: if (null === $verifyip) {
294: $verifyip = (bool)array_get(\Preferences::factory($this->getAuthContext()),
295: LetsencryptAlias\Preferences::VERIFY_IP, LETSENCRYPT_VERIFY_IP);
296: }
297:
298: if (null === $strict) {
299: $strict = (bool)array_get(\Preferences::factory($this->getAuthContext()),
300: LetsencryptAlias\Preferences::SENSITIVITY, LETSENCRYPT_STRICT_MODE);
301: }
302:
303: $cnreq = array();
304: if ($this->permission_level & PRIVILEGE_ADMIN) {
305: // admin works differently, check configured certificates versus what is provided
306: // request behavior overrides certificate set, which requires updating
307: // @TODO update for consistency, WIP for debugging
308: return $this->requestReal((array)$cnames, LetsencryptAlias::SYSCERT_NAME, $strict) && $this->_moveCertificates(LetsencryptAlias::SYSCERT_NAME);
309: }
310: $myip = ($this->permission_level & PRIVILEGE_ADMIN) ? \Opcenter\Net\Ip4::my_ip() : $this->dns_get_public_ip();
311: // @TODO normalize certificates, prune duplicates based on WC
312: foreach ((array)$cnames as $c) {
313: $isWildcard = false;
314: if (!is_string($c)) {
315: error('Skipping garbled input - hostname not presented as string, is %s', gettype($c));
316: }
317: if (0 === strncmp($c, '*.', 2)) {
318: $c = substr($c, 2);
319: $isWildcard = true;
320: }
321: $c = $this->web_split_host($c);
322: $domain = $c['domain'];
323: $subdomain = $c['subdomain'];
324:
325: if (!$this->web_domain_exists($domain)) {
326: error("cannot process Let's Encrypt: domain `%s' not a valid domain on this account",
327: $domain);
328: if ($strict) {
329: return false;
330: }
331: continue;
332: }
333:
334: $host = ltrim($subdomain . '.' . $domain, '.');
335: if (!preg_match(Regex::HTTP_HOST, $host)) {
336: error("invalid server name `%s' specified", $c);
337: if ($strict) {
338: return false;
339: }
340: continue;
341: }
342:
343: if ($verifyip && self::INCLUDE_ALT_FORM && !$isWildcard) {
344: $altform = null;
345: if (0 !== strpos($host, 'www.')) {
346: // add www.example.com if example.com given
347: $altform = 'www.' . $host;
348: } else {
349: // add example.com if www.example.com given
350: $altform = substr($host, 4);
351: }
352:
353: $resolved = null;
354: if ($this->_verifyIP($altform, (array)$myip, $resolved)) {
355: $cnreq[] = $altform;
356: } else if ($strict) {
357: return error("Domain `%s' would be dropped from renewal", $altform);
358: } else {
359: info("skipping alternative hostname form `%(hostname)s', IP %(actual)s does not resolve to `%(expected)s'", [
360: 'hostname' => $altform,
361: 'expected' => $myip,
362: 'actual' => $resolved
363: ]);
364: }
365: }
366:
367: $resolved = null;
368: if ($verifyip && !$this->_verifyIP($host, (array)$myip, $resolved)) {
369: $msg = [
370: "hostname `%(hostname)s' IP `%(actual)s' doesn't match hosting IP `%(expected)s', "
371: . '%(what)s request', [
372: 'hostname' => $host,
373: 'actual' => $resolved,
374: 'expected' => implode(',', (array)$myip),
375: 'what' =>$strict ? 'aborting' : 'skipping'
376: ]
377: ];
378: if ($strict) {
379: return error(...$msg);
380: }
381: warn(...$msg);
382: continue;
383: }
384: if ($isWildcard) {
385: $host = '*.' . $host;
386: }
387: $cnreq[] = $host;
388: }
389: if (!$cnreq) {
390: error('no hostnames to register');
391:
392: return null;
393: }
394:
395: $storageMarker = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
396: if (! ($ret = $this->requestReal($cnreq, $storageMarker, $strict)) ) {
397: return $ret;
398: }
399:
400: if ($strict && ($hosts = $this->filterMissingHostnames($cnames))) {
401: return error('Failed to append hostnames. Hostnames missing from new certificate: %s',
402: implode(', ', $hosts)
403: );
404: }
405:
406: if (!$this->_moveCertificates($storageMarker)) {
407: return false;
408: }
409:
410: return info(':letsencrypt_issuance_limit',
411: 'reminder: only 5 duplicate certificates and ' .
412: '50 unique certificates may be issued per week per account'
413: );
414: }
415:
416: /**
417: * Verify hostname matches IP
418: *
419: * LE will fail issuance if request fails,
420: * verify the challenge points to this server
421: *
422: * @param $hostname
423: * @param $myip
424: * @return bool
425: */
426: private function _verifyIP($hostname, array $myip, string &$resolved = null)
427: {
428: for ($i = 0; $i < 2; $i++) {
429: $time = microtime(true);
430: if ($resolved = (string)$this->dns_gethostbyname_t($hostname)) {
431: break;
432: }
433: $now = microtime(true) - $time;
434: warn('DNS resolver failed to return answer in %dms', $now * 1000);
435: usleep(500000);
436: }
437:
438: if (!$resolved) {
439: return false;
440: }
441:
442: foreach ($myip as $chkip) {
443: if ($chkip === $resolved) {
444: return true;
445: }
446: }
447: return false;
448: }
449:
450: private function _moveCertificates($site)
451: {
452: if ($site === LetsencryptAlias::SYSCERT_NAME) {
453: return $this->installSystemCertificate();
454: }
455: $files = LetsencryptAlias::getCertificateComponentData($site);
456: if (!$files) {
457: return false;
458: }
459:
460: return $this->ssl_install($files['key'], $files['crt'], $files['chain']);
461: }
462:
463: /**
464: * Certificate is generated by LE
465: *
466: * @param string $crt certificate data
467: * @return bool
468: */
469: public function is_ca($crt)
470: {
471: $cert = $this->ssl_parse_certificate($crt);
472: if (!$cert) {
473: return error('invalid ssl certificate');
474: }
475:
476: if (!isset($cert['extensions']['authorityKeyIdentifier'])) {
477: return false;
478: }
479: $authority = $cert['extensions']['authorityKeyIdentifier'];
480: $prefix = 'keyid:';
481: if (!strncmp($authority, $prefix, strlen($prefix))) {
482: $authority = substr($authority, strlen($prefix));
483: }
484: $authority = trim($authority);
485:
486: return in_array($authority, self::LE_AUTHORITY_FINGERPRINT, true) ||
487: in_array($authority, self::LE_STAGING_AUTHORITY_FINGERPRINT, true);
488: }
489:
490: /**
491: * Append hostnames to request in non-destructive manner
492: *
493: * @param $cnames
494: * @param bool|null $verifyip
495: * @return bool
496: */
497: public function append(string|array $cnames, bool $verifyip = null): bool
498: {
499: $cnames = array_flip((array)$cnames);
500: if ($this->ssl_cert_exists() && null === ($old = $this->getNonOrphanedDomainsFromCertificate())) {
501: return warn("Cannot append hostnames to non-Let's Encrypt certificate");
502: }
503: $old = $old ?? [];
504: $new = self::filterDomainSet(array_keys($cnames + array_flip($old)));
505:
506: if (!array_diff($new, $old)) {
507: // no change
508: return true;
509: }
510:
511: return (bool)$this->request($new, $verifyip);
512: }
513:
514: private function getNonOrphanedDomainsFromCertificate(): ?array
515: {
516: if (null === ($cns = $this->getSanFromCertificate())) {
517: // not Let's Encrypt
518: return null;
519: }
520:
521: if ($this->permission_level & PRIVILEGE_ADMIN) {
522: return $cns;
523: }
524:
525: $strict = (bool)array_get(
526: \Preferences::factory($this->getAuthContext()),
527: LetsencryptAlias\Preferences::SENSITIVITY,
528: LETSENCRYPT_STRICT_MODE
529: );
530:
531: return array_filter($cns, function ($hostname) use ($strict) {
532: if (0 === strncmp($hostname, '*.', 2)) {
533: $hostname = substr($hostname, 2);
534: }
535: $components = $this->web_split_host($hostname);
536: $domain = $components['domain'];
537:
538: if (!$strict && !$this->web_domain_exists($domain)) {
539: warn("Domain %s missing from account, removing from certificate", $domain);
540:
541: return false;
542: }
543:
544: return true;
545: });
546: }
547:
548: /**
549: * Invalidate issued certificate
550: *
551: * @return bool
552: */
553: public function revoke(): bool
554: {
555: if (!IS_CLI) {
556: return $this->query('letsencrypt_revoke');
557: }
558:
559: if (!$this->certificateIssued()) {
560: return error('no certificate issued to revoke');
561: }
562:
563: $cert = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
564: $ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$cert, $this->activeServer])
565: ->revoke();
566:
567: if (!$ret) {
568: return error('revocation failed');
569: }
570:
571: $this->_deleteAcmeCertificate($cert);
572:
573: return true;
574: }
575:
576: private function _deleteAcmeCertificate($account)
577: {
578: $acmeDir = LetsencryptAlias::acmeSiteStorageDirectory($account);
579: if (!file_exists($acmeDir)) {
580: return;
581: }
582: $dir = opendir($acmeDir);
583: while (false !== ($f = readdir($dir))) {
584: if ($f === '..' || $f === '.') {
585: continue;
586: }
587: unlink($acmeDir . '/' . $f);
588:
589: }
590: closedir($dir);
591: rmdir($acmeDir);
592:
593: return;
594:
595: }
596:
597: public function exists(): bool
598: {
599: $path = LetsencryptAlias::acmeSiteStorageDirectory($this->site);
600:
601: return file_exists($path);
602: }
603:
604: /**
605: * Retrieve absolute storage path for site certificate
606: *
607: * @param string $site
608: * @return string
609: */
610: public function storage_path(string $site): string
611: {
612: return LetsencryptAlias::acmeSiteStorageDirectory($site);
613: }
614:
615: /**
616: * Request a SSL certificate for all domains on the account
617: *
618: * bootstrap verifies IP address before attempt
619: *
620: * @param int|null $attempt attempt counter
621: * @return bool|null boolean on success (or error), null on reschedule
622: */
623: public function bootstrap(?int $attempt = null): ?bool
624: {
625: if ($attempt !== null && ($attempt > 10 || $attempt < 0)) {
626: return error('Invalid attempt count provided: %d', $attempt);
627: }
628:
629: $domains = \Opcenter\Crypto\Ssl::generateHostnames($this->getAuthContext(), true);
630: if (\count($domains) > 100) {
631: warn('Hostname count exceeds 100 (%d hostnames). Taking first 100 hostnames', \count($domains));
632: $domains = array_slice($domains, 0, 100);
633: }
634: if ($attempt !== null && $this->request($domains, true)) {
635: // @todo send email
636: return true;
637: }
638: // retry or initial attempt
639: $delay = 43200;
640: if ($attempt === null) {
641: $attempt = static::BOOTSTRAP_ATTEMPTS;
642: $delay = 0;
643: } else if (--$attempt < 0) {
644: return error('Failed to bootstrap SSL');
645: }
646: $er = \Error_Reporter::flush_buffer();
647: $job = \Lararia\Jobs\Job::create(
648: \Lararia\Jobs\SimpleCommandJob::class,
649: $this->getAuthContext(),
650: 'letsencrypt_bootstrap',
651: $attempt
652: );
653: $job->setTags([$this->site, 'letsencrypt_bootstrap']);
654: $job->delayedDispatch($delay);
655: \Error_Reporter::merge_buffer($er);
656: info('Scheduled letsencrypt:bootstrap job');
657: return null;
658: }
659:
660: public function _housekeeping()
661: {
662: // Let's Encrypt supported on Luna + Sol only
663: if (!$this->supported()) {
664: return;
665: }
666: if (!$this->_registered() && !$this->_register()) {
667: return error("failed to register with Let's Encrypt");
668: }
669:
670: $this->renew_expiring();
671: }
672:
673: private function _registered()
674: {
675: $key = str_replace(['/'], '.', $this->activeServer) . '.pem';
676: $storageDir = LetsencryptAlias::acmeDataDirectory();
677:
678: return file_exists($storageDir . '/accounts/' . $key);
679: }
680:
681: private function _register($email = null)
682: {
683: $acctdir = LetsencryptAlias::acmeDataDirectory();
684: if (!file_exists($acctdir)) {
685: mkdir($acctdir, 0700, true);
686: }
687:
688: $email = $this->admin_get_email() ?: \Crm_Module::FROM_ADDRESS;
689:
690: if (!$email) {
691: return error("Cannot register Let's Encrypt without an email address. Run 'cpcmd common_set_email newemail' from command-line.");
692: }
693:
694: $marker = $this->site ?? Opcenter\Crypto\Letsencrypt::SYSCERT_NAME;
695: $ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$marker, $this->activeServer])
696: ->register($email);
697: if (!$ret) {
698: return error("Let's Encrypt registration failed");
699: }
700:
701: return true;
702: }
703:
704: public function _edit()
705: {
706: $conf_new = $this->getAuthContext()->getAccount()->new;
707: $conf_cur = $this->getAuthContext()->getAccount()->old;
708: $ssl = \Opcenter\SiteConfiguration::getModuleRemap('openssl');
709: if (!$conf_new[$ssl]['enabled']) {
710: $this->_delete();
711: }
712: }
713:
714: public function _delete()
715: {
716: $this->_deleteAcmeCertificate($this->site);
717: }
718:
719: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
720: {
721: return true;
722: }
723:
724: public function _create()
725: {
726: return;
727: }
728:
729: public function _create_user(string $user)
730: {
731: return;
732: }
733:
734: public function _delete_user(string $user)
735: {
736: return;
737: }
738:
739: public function _edit_user(string $userold, string $usernew, array $oldpwd)
740: {
741: return;
742: }
743:
744: public function _reload(string $why = '', array $args = [])
745: {
746: if ($why === \Ssl_Module::USER_RHOOK) {
747: $crt = $this->ssl_get_certificate();
748: if ($this->letsencrypt_is_ca($crt)) {
749: return;
750: }
751:
752: // moved from LE to non-LE certificate
753: if (is_dir($path = LEService::acmeSiteStorageDirectory($this->site))) {
754: Opcenter\Filesystem::rmdir($path);
755: }
756: }
757: }
758: }