| 1: | <?php |
| 2: | declare(strict_types=1); |
| 3: | |
| 4: | |
| 5: | |
| 6: | |
| 7: | |
| 8: | |
| 9: | |
| 10: | |
| 11: | |
| 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: | |
| 23: | |
| 24: | |
| 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: | |
| 32: | const BOOTSTRAP_ATTEMPTS = LETSENCRYPT_BOOTSTRAP_ATTEMPTS; |
| 33: | |
| 34: | const LETSENCRYPT_SERVER = 'acme-v02.api.letsencrypt.org/directory'; |
| 35: | |
| 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: | |
| 40: | const INCLUDE_ALT_FORM = LETSENCRYPT_ALTERNATIVE_FORM; |
| 41: | protected $activeServer; |
| 42: | |
| 43: | |
| 44: | |
| 45: | |
| 46: | |
| 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: | |
| 78: | |
| 79: | |
| 80: | |
| 81: | public function supported() |
| 82: | { |
| 83: | return true; |
| 84: | } |
| 85: | |
| 86: | |
| 87: | |
| 88: | |
| 89: | |
| 90: | |
| 91: | public function permitted() |
| 92: | { |
| 93: | return $this->supported() && $this->ssl_permitted(); |
| 94: | } |
| 95: | |
| 96: | |
| 97: | |
| 98: | |
| 99: | |
| 100: | |
| 101: | public function debug(): bool |
| 102: | { |
| 103: | return (bool)LETSENCRYPT_DEBUG; |
| 104: | } |
| 105: | |
| 106: | |
| 107: | |
| 108: | |
| 109: | |
| 110: | |
| 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: | |
| 130: | |
| 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: | |
| 142: | |
| 143: | |
| 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: | |
| 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: | |
| 162: | |
| 163: | |
| 164: | |
| 165: | |
| 166: | |
| 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: | |
| 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: | |
| 199: | |
| 200: | |
| 201: | |
| 202: | |
| 203: | |
| 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: | |
| 235: | |
| 236: | |
| 237: | |
| 238: | |
| 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: | |
| 260: | |
| 261: | |
| 262: | |
| 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: | |
| 279: | |
| 280: | |
| 281: | |
| 282: | |
| 283: | |
| 284: | |
| 285: | |
| 286: | |
| 287: | |
| 288: | public function request($cnames, ?bool $verifyip = null, ?bool $strict = null): ?bool |
| 289: | { |
| 290: | |
| 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: | |
| 308: | |
| 309: | |
| 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: | |
| 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: | |
| 343: | $altform = 'www.' . $host; |
| 344: | } else { |
| 345: | |
| 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: | |
| 414: | |
| 415: | |
| 416: | |
| 417: | |
| 418: | |
| 419: | |
| 420: | |
| 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: | |
| 461: | |
| 462: | |
| 463: | |
| 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: | |
| 485: | |
| 486: | |
| 487: | |
| 488: | |
| 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: | |
| 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: | |
| 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: | |
| 543: | |
| 544: | |
| 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: | |
| 599: | |
| 600: | |
| 601: | |
| 602: | |
| 603: | public function storage_path(string $site): string |
| 604: | { |
| 605: | return LetsencryptAlias::acmeSiteStorageDirectory($site); |
| 606: | } |
| 607: | |
| 608: | |
| 609: | |
| 610: | |
| 611: | |
| 612: | |
| 613: | |
| 614: | |
| 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: | |
| 629: | return true; |
| 630: | } |
| 631: | |
| 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: | |
| 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 ($crt && $this->letsencrypt_is_ca($crt)) { |
| 742: | return; |
| 743: | } |
| 744: | |
| 745: | |
| 746: | if (is_dir($path = LEService::acmeSiteStorageDirectory($this->site))) { |
| 747: | Opcenter\Filesystem::rmdir($path); |
| 748: | } |
| 749: | } |
| 750: | } |
| 751: | } |