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