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: continue;
329: }
330:
331: $host = ltrim($subdomain . '.' . $domain, '.');
332: if (!preg_match(Regex::HTTP_HOST, $host)) {
333: error("invalid server name `%s' specified", $c);
334: continue;
335: }
336:
337: if ($verifyip && self::INCLUDE_ALT_FORM && !$isWildcard) {
338: $altform = null;
339: if (0 !== strpos($host, 'www.')) {
340: // add www.example.com if example.com given
341: $altform = 'www.' . $host;
342: } else {
343: // add example.com if www.example.com given
344: $altform = substr($host, 4);
345: }
346:
347: $resolved = null;
348: if ($this->_verifyIP($altform, (array)$myip, $resolved)) {
349: $cnreq[] = $altform;
350: } else if ($strict) {
351: return error("Domain `%s' would be dropped from renewal", $altform);
352: } else {
353: info("skipping alternative hostname form `%(hostname)s', IP %(actual)s does not resolve to `%(expected)s'", [
354: 'hostname' => $altform,
355: 'expected' => $myip,
356: 'actual' => $resolved
357: ]);
358: }
359: }
360:
361: $resolved = null;
362: if ($verifyip && !$this->_verifyIP($host, (array)$myip, $resolved)) {
363: $msg = [
364: "hostname `%(hostname)s' IP `%(actual)s' doesn't match hosting IP `%(expected)s', "
365: . '%(what)s request', [
366: 'hostname' => $host,
367: 'actual' => $resolved,
368: 'expected' => implode(',', (array)$myip),
369: 'what' =>$strict ? 'aborting' : 'skipping'
370: ]
371: ];
372: if ($strict) {
373: return error(...$msg);
374: }
375: warn(...$msg);
376: continue;
377: }
378: if ($isWildcard) {
379: $host = '*.' . $host;
380: }
381: $cnreq[] = $host;
382: }
383: if (!$cnreq) {
384: error('no hostnames to register');
385:
386: return null;
387: }
388:
389: $storageMarker = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
390: if (! ($ret = $this->requestReal($cnreq, $storageMarker, $strict)) ) {
391: return $ret;
392: }
393:
394: if ($strict && ($hosts = $this->filterMissingHostnames($cnames))) {
395: return error('Failed to append hostnames. Hostnames missing from new certificate: %s',
396: implode(', ', $hosts)
397: );
398: }
399:
400: if (!$this->_moveCertificates($storageMarker)) {
401: return false;
402: }
403:
404: return info(':letsencrypt_issuance_limit',
405: 'reminder: only 5 duplicate certificates and ' .
406: '50 unique certificates may be issued per week per account'
407: );
408: }
409:
410: /**
411: * Verify hostname matches IP
412: *
413: * LE will fail issuance if request fails,
414: * verify the challenge points to this server
415: *
416: * @param $hostname
417: * @param $myip
418: * @return bool
419: */
420: private function _verifyIP($hostname, array $myip, string &$resolved = null)
421: {
422: for ($i = 0; $i < 2; $i++) {
423: $time = microtime(true);
424: if ($resolved = (string)$this->dns_gethostbyname_t($hostname)) {
425: break;
426: }
427: $now = microtime(true) - $time;
428: warn('DNS resolver failed to return answer in %dms', $now * 1000);
429: usleep(500000);
430: }
431:
432: if (!$resolved) {
433: return false;
434: }
435:
436: foreach ($myip as $chkip) {
437: if ($chkip === $resolved) {
438: return true;
439: }
440: }
441: return false;
442: }
443:
444: private function _moveCertificates($site)
445: {
446: if ($site === LetsencryptAlias::SYSCERT_NAME) {
447: return $this->installSystemCertificate();
448: }
449: $files = LetsencryptAlias::getCertificateComponentData($site);
450: if (!$files) {
451: return false;
452: }
453:
454: return $this->ssl_install($files['key'], $files['crt'], $files['chain']);
455: }
456:
457: /**
458: * Certificate is generated by LE
459: *
460: * @param string $crt certificate data
461: * @return bool
462: */
463: public function is_ca($crt)
464: {
465: $cert = $this->ssl_parse_certificate($crt);
466: if (!$cert) {
467: return error('invalid ssl certificate');
468: }
469:
470: if (!isset($cert['extensions']['authorityKeyIdentifier'])) {
471: return false;
472: }
473: $authority = $cert['extensions']['authorityKeyIdentifier'];
474: $prefix = 'keyid:';
475: if (!strncmp($authority, $prefix, strlen($prefix))) {
476: $authority = substr($authority, strlen($prefix));
477: }
478: $authority = trim($authority);
479:
480: return in_array($authority, self::LE_AUTHORITY_FINGERPRINT, true) ||
481: in_array($authority, self::LE_STAGING_AUTHORITY_FINGERPRINT, true);
482: }
483:
484: /**
485: * Append hostnames to request in non-destructive manner
486: *
487: * @param $cnames
488: * @param bool|null $verifyip
489: * @return bool
490: */
491: public function append(string|array $cnames, bool $verifyip = null): bool
492: {
493: $cnames = array_flip((array)$cnames);
494: if ($this->ssl_cert_exists() && null === ($old = $this->getNonOrphanedDomainsFromCertificate())) {
495: return warn("Cannot append hostnames to non-Let's Encrypt certificate");
496: }
497: $old = $old ?? [];
498: $new = self::filterDomainSet(array_keys($cnames + array_flip($old)));
499:
500: if (!array_diff($new, $old)) {
501: // no change
502: return true;
503: }
504:
505: return (bool)$this->request($new, $verifyip);
506: }
507:
508: private function getNonOrphanedDomainsFromCertificate(): ?array
509: {
510: if (null === ($cns = $this->getSanFromCertificate())) {
511: // not Let's Encrypt
512: return null;
513: }
514:
515: if ($this->permission_level & PRIVILEGE_ADMIN) {
516: return $cns;
517: }
518:
519: $strict = (bool)array_get(
520: \Preferences::factory($this->getAuthContext()),
521: LetsencryptAlias\Preferences::SENSITIVITY,
522: LETSENCRYPT_STRICT_MODE
523: );
524:
525: return array_filter($cns, function ($hostname) use ($strict) {
526: if (0 === strncmp($hostname, '*.', 2)) {
527: $hostname = substr($hostname, 2);
528: }
529: $components = $this->web_split_host($hostname);
530: $domain = $components['domain'];
531:
532: if (!$strict && !$this->web_domain_exists($domain)) {
533: warn("Domain %s missing from account, removing from certificate", $domain);
534:
535: return false;
536: }
537:
538: return true;
539: });
540: }
541:
542: /**
543: * Invalidate issued certificate
544: *
545: * @return bool
546: */
547: public function revoke(): bool
548: {
549: if (!IS_CLI) {
550: return $this->query('letsencrypt_revoke');
551: }
552:
553: if (!$this->certificateIssued()) {
554: return error('no certificate issued to revoke');
555: }
556:
557: $cert = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
558: $ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$cert, $this->activeServer])
559: ->revoke();
560:
561: if (!$ret) {
562: return error('revocation failed');
563: }
564:
565: $this->_deleteAcmeCertificate($cert);
566:
567: return true;
568: }
569:
570: private function _deleteAcmeCertificate($account)
571: {
572: $acmeDir = LetsencryptAlias::acmeSiteStorageDirectory($account);
573: if (!file_exists($acmeDir)) {
574: return;
575: }
576: $dir = opendir($acmeDir);
577: while (false !== ($f = readdir($dir))) {
578: if ($f === '..' || $f === '.') {
579: continue;
580: }
581: unlink($acmeDir . '/' . $f);
582:
583: }
584: closedir($dir);
585: rmdir($acmeDir);
586:
587: return;
588:
589: }
590:
591: public function exists(): bool
592: {
593: $path = LetsencryptAlias::acmeSiteStorageDirectory($this->site);
594:
595: return file_exists($path);
596: }
597:
598: /**
599: * Retrieve absolute storage path for site certificate
600: *
601: * @param string $site
602: * @return string
603: */
604: public function storage_path(string $site): string
605: {
606: return LetsencryptAlias::acmeSiteStorageDirectory($site);
607: }
608:
609: /**
610: * Request a SSL certificate for all domains on the account
611: *
612: * bootstrap verifies IP address before attempt
613: *
614: * @param int|null $attempt attempt counter
615: * @return bool|null boolean on success (or error), null on reschedule
616: */
617: public function bootstrap(?int $attempt = null): ?bool
618: {
619: if ($attempt !== null && ($attempt > 10 || $attempt < 0)) {
620: return error('Invalid attempt count provided: %d', $attempt);
621: }
622:
623: $domains = \Opcenter\Crypto\Ssl::generateHostnames($this->getAuthContext(), true);
624: if (\count($domains) > 100) {
625: warn('Hostname count exceeds 100 (%d hostnames). Taking first 100 hostnames', \count($domains));
626: $domains = array_slice($domains, 0, 100);
627: }
628: if ($attempt !== null && $this->request($domains, true)) {
629: // @todo send email
630: return true;
631: }
632: // retry or initial attempt
633: $delay = 43200;
634: if ($attempt === null) {
635: $attempt = static::BOOTSTRAP_ATTEMPTS;
636: $delay = 0;
637: } else if (--$attempt < 0) {
638: return error('Failed to bootstrap SSL');
639: }
640: $er = \Error_Reporter::flush_buffer();
641: $job = \Lararia\Jobs\Job::create(
642: \Lararia\Jobs\SimpleCommandJob::class,
643: $this->getAuthContext(),
644: 'letsencrypt_bootstrap',
645: $attempt
646: );
647: $job->setTags([$this->site, 'letsencrypt_bootstrap']);
648: $job->delayedDispatch($delay);
649: \Error_Reporter::merge_buffer($er);
650: info('Scheduled letsencrypt:bootstrap job');
651: return null;
652: }
653:
654: public function _housekeeping()
655: {
656: // Let's Encrypt supported on Luna + Sol only
657: if (!$this->supported()) {
658: return;
659: }
660: if (!$this->_registered() && !$this->_register()) {
661: return error("failed to register with Let's Encrypt");
662: }
663:
664: $this->renew_expiring();
665: }
666:
667: private function _registered()
668: {
669: $key = str_replace(['/'], '.', $this->activeServer) . '.pem';
670: $storageDir = LetsencryptAlias::acmeDataDirectory();
671:
672: return file_exists($storageDir . '/accounts/' . $key);
673: }
674:
675: private function _register($email = null)
676: {
677: $acctdir = LetsencryptAlias::acmeDataDirectory();
678: if (!file_exists($acctdir)) {
679: mkdir($acctdir, 0700, true);
680: }
681:
682: $email = $this->admin_get_email() ?: \Crm_Module::FROM_ADDRESS;
683:
684: if (!$email) {
685: return error("Cannot register Let's Encrypt without an email address. Run 'cpcmd common_set_email newemail' from command-line.");
686: }
687:
688: $marker = $this->site ?? Opcenter\Crypto\Letsencrypt::SYSCERT_NAME;
689: $ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$marker, $this->activeServer])
690: ->register($email);
691: if (!$ret) {
692: return error("Let's Encrypt registration failed");
693: }
694:
695: return true;
696: }
697:
698: public function _edit()
699: {
700: $conf_new = $this->getAuthContext()->getAccount()->new;
701: $conf_cur = $this->getAuthContext()->getAccount()->old;
702: $ssl = \Opcenter\SiteConfiguration::getModuleRemap('openssl');
703: if (!$conf_new[$ssl]['enabled']) {
704: $this->_delete();
705: }
706: }
707:
708: public function _delete()
709: {
710: $this->_deleteAcmeCertificate($this->site);
711: }
712:
713: public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
714: {
715: return true;
716: }
717:
718: public function _create()
719: {
720: return;
721: }
722:
723: public function _create_user(string $user)
724: {
725: return;
726: }
727:
728: public function _delete_user(string $user)
729: {
730: return;
731: }
732:
733: public function _edit_user(string $userold, string $usernew, array $oldpwd)
734: {
735: return;
736: }
737:
738: public function _reload(string $why = '', array $args = [])
739: {
740: if ($why === \Ssl_Module::USER_RHOOK) {
741: $crt = $this->ssl_get_certificate();
742: if ($this->letsencrypt_is_ca($crt)) {
743: return;
744: }
745:
746: // moved from LE to non-LE certificate
747: if (is_dir($path = LEService::acmeSiteStorageDirectory($this->site))) {
748: Opcenter\Filesystem::rmdir($path);
749: }
750: }
751: }
752: }