1: 2: 3: 4: 5: 6: 7: 8: 9: 10: 11: 12: 13: 14: 15: 16: 17: 18: 19: 20: 21: 22: 23: 24: 25: 26: 27: 28: 29: 30: 31: 32: 33: 34: 35: 36: 37: 38: 39: 40: 41: 42: 43: 44: 45: 46: 47: 48: 49: 50: 51: 52: 53: 54: 55: 56: 57: 58: 59: 60: 61: 62: 63: 64: 65: 66: 67: 68: 69: 70: 71: 72: 73: 74: 75: 76: 77: 78: 79: 80: 81: 82: 83: 84: 85: 86: 87: 88: 89: 90: 91: 92: 93: 94: 95: 96: 97: 98: 99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 306: 307: 308: 309: 310: 311: 312: 313: 314: 315: 316: 317: 318: 319: 320: 321: 322: 323: 324: 325: 326: 327: 328: 329: 330: 331: 332: 333: 334: 335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346: 347: 348: 349: 350: 351: 352: 353: 354: 355: 356: 357: 358: 359: 360: 361: 362: 363: 364: 365: 366: 367: 368: 369: 370: 371: 372: 373: 374: 375: 376: 377: 378: 379: 380: 381: 382: 383: 384: 385: 386: 387: 388: 389: 390: 391: 392: 393: 394: 395: 396: 397: 398: 399: 400: 401: 402: 403: 404: 405: 406: 407: 408: 409: 410: 411: 412: 413: 414: 415: 416: 417: 418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429: 430: 431: 432: 433: 434: 435: 436: 437: 438: 439: 440: 441: 442: 443: 444: 445: 446: 447: 448: 449: 450: 451: 452: 453: 454: 455: 456: 457: 458: 459: 460: 461: 462: 463: 464: 465: 466: 467: 468: 469: 470: 471: 472: 473: 474: 475: 476: 477: 478: 479: 480: 481: 482: 483: 484: 485: 486: 487: 488: 489: 490: 491: 492: 493: 494: 495: 496: 497: 498: 499: 500: 501: 502: 503: 504: 505: 506: 507: 508: 509: 510: 511: 512: 513: 514: 515: 516: 517: 518: 519: 520: 521: 522: 523: 524: 525: 526: 527: 528: 529: 530: 531: 532: 533: 534: 535: 536: 537: 538: 539: 540: 541: 542: 543: 544: 545: 546: 547: 548: 549: 550: 551: 552: 553: 554: 555: 556: 557: 558: 559: 560: 561: 562: 563: 564: 565: 566: 567: 568: 569: 570: 571: 572: 573: 574: 575: 576: 577: 578: 579: 580: 581: 582: 583: 584: 585: 586: 587: 588: 589: 590: 591: 592: 593: 594: 595: 596: 597: 598: 599: 600: 601: 602: 603: 604: 605: 606: 607: 608: 609: 610: 611: 612: 613: 614: 615: 616: 617: 618: 619: 620: 621: 622: 623: 624: 625: 626: 627: 628: 629: 630: 631: 632: 633: 634: 635: 636: 637: 638: 639: 640: 641: 642: 643: 644: 645: 646: 647: 648: 649: 650: 651: 652: 653: 654: 655: 656: 657: 658: 659: 660: 661: 662: 663: 664: 665: 666: 667: 668: 669: 670:
<?php
declare(strict_types=1);
use AcmePhp\Core\Exception\AcmeCoreServerException;
use AcmePhp\Core\Protocol\AuthorizationChallenge;
use Module\Support\Letsencrypt;
use Opcenter\Crypto\Letsencrypt as LetsencryptAlias;
class Letsencrypt_Module extends Letsencrypt implements \Opcenter\Contracts\Hookable
{
const DEPENDENCY_MAP = [
'ssl'
];
const BOOTSTRAP_ATTEMPTS = LETSENCRYPT_BOOTSTRAP_ATTEMPTS;
const LETSENCRYPT_SERVER = 'acme-v02.api.letsencrypt.org/directory';
const LETSENCRYPT_TESTING_SERVER = 'acme-staging-v02.api.letsencrypt.org/directory';
protected const LE_AUTHORITY_FINGERPRINT = LETSENCRYPT_KEYID;
protected const LE_STAGING_AUTHORITY_FINGERPRINT = LETSENCRYPT_STAGING_KEYID;
const INCLUDE_ALT_FORM = LETSENCRYPT_ALTERNATIVE_FORM;
const DNS_VERIFY_IP_TIMEOUT = 1500;
protected $activeServer;
public function __construct()
{
parent::__construct();
$this->activeServer = \Opcenter\Crypto\Letsencrypt::activeServer();
if ($this->supported()) {
$fns = array(
'*' => PRIVILEGE_SITE,
'request' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
'append' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
'renew_expiring' => PRIVILEGE_ADMIN,
'revoke' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
'challenges' => PRIVILEGE_SITE|PRIVILEGE_ADMIN,
'solve' => PRIVILEGE_SITE | PRIVILEGE_ADMIN,
'renew' => PRIVILEGE_SITE|PRIVILEGE_ADMIN
);
} else {
$fns = array(
'supported' => PRIVILEGE_SITE,
'permitted' => PRIVILEGE_SITE,
'is_ca' => PRIVILEGE_SITE,
'*' => PRIVILEGE_NONE
);
}
$this->exportedFunctions = $fns;
}
public function supported()
{
return true;
}
public function permitted()
{
return $this->supported() && $this->ssl_permitted();
}
public function debug(): bool
{
return (bool)LETSENCRYPT_DEBUG;
}
public function renew(bool $verifyip = null)
{
if (null === $verifyip) {
$verifyip = (bool)array_get(\Preferences::factory($this->getAuthContext()), LetsencryptAlias\Preferences::VERIFY_IP, true);
}
if ($this->permission_level & PRIVILEGE_SITE && $this->auth_is_inactive()) {
return error("account `%s' is inactive - not renewing SSL", $this->domain);
}
$cns = $this->getSanFromCertificate();
if ($cns === []) {
return error('no certificates installed on account');
} else if ($cns === null) {
return warn("certificate for `%s' is not provided by LE", $this->domain);
}
$ret = $this->request($cns, $verifyip);
if (null === $ret) {
return warn('request failed, lack of valid hostnames to renew');
}
if (!$ret) {
return error('failed to renew certificate');
}
return info('successfully renewed certificate for 90 days');
}
public function renew_expiring(): void {
$this->renewExpiringCertificates();
if (!$this->systemNeedsIssuance()) {
return;
}
$cns = [SERVER_NAME];
if (SERVER_NAME !== ($name = gethostname())) {
$cns[] = $name;
}
if (LETSENCRYPT_ADDITIONAL_CERTS) {
$cns = array_merge($cns, LETSENCRYPT_ADDITIONAL_CERTS);
}
if ($this->requestReal($cns, LetsencryptAlias::SYSCERT_NAME)) {
$this->installSystemCertificate();
}
}
public function challenges($hostnames): array
{
$hostnames = (array)$hostnames;
$sans = [];
foreach ($hostnames as $host) {
$chk = $host;
if (0 === strncmp($host, '*.', 2)) {
$chk = substr($chk, 2);
}
if (($this->permission_level & PRIVILEGE_SITE) && !$this->web_split_host($chk)) {
error("Invalid hostname `%s'", $chk);
continue;
}
$sans[$host] = null;
}
$dispatch = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$this->site]);
if ( !($challenges = $dispatch->challenges(array_keys($sans))) ) {
return [];
}
foreach (array_get($challenges->toArray(), 'authorizationsChallenges', []) as $domain => $challengeTypes) {
$sans[$domain] = array_map(static function (AuthorizationChallenge $c) {
return $c->toArray();
}, $challengeTypes);
}
return $sans;
}
public function solve($hostname, string $solver = null): bool
{
$dispatch = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$this->site]);
$dispatch->setStagingOnly(true);
if ($solver) {
array_fill_keys((array)$hostname, $solver);
} else if (!\is_array($hostname) || isset($hostname[0])) {
return error('$hostname parameter is not a map of hostnames => solvers');
}
$order = $dispatch->challenges((array)array_keys($hostname));
$challengeSet = $order->getAuthorizationsChallenges();
foreach ($challengeSet as $host => &$challenges) {
foreach ($challenges as &$challenge) {
if (!isset($hostname[$host]) || 0 !== strpos($challenge->getType(), $hostname[$host])) {
$challenge = null;
continue;
}
}
unset($challenge);
$challenges = array_filter($challenges);
}
unset($challenges);
$revised = new \AcmePhp\Core\Protocol\CertificateOrder($challengeSet);
return null === $dispatch->solve($revised);
}
public function request($cnames, ?bool $verifyip = null, ?bool $strict = null): ?bool
{
if (posix_geteuid() && !IS_CLI) {
return $this->query('letsencrypt_request', $cnames, $verifyip, $strict);
}
if (null === $verifyip) {
$verifyip = (bool)array_get(\Preferences::factory($this->getAuthContext()),
LetsencryptAlias\Preferences::VERIFY_IP, LETSENCRYPT_VERIFY_IP);
}
if (null === $strict) {
$strict = (bool)array_get(\Preferences::factory($this->getAuthContext()),
LetsencryptAlias\Preferences::SENSITIVITY, LETSENCRYPT_STRICT_MODE);
}
$cnreq = array();
if ($this->permission_level & PRIVILEGE_ADMIN) {
return $this->requestReal((array)$cnames, LetsencryptAlias::SYSCERT_NAME, $strict) && $this->_moveCertificates(LetsencryptAlias::SYSCERT_NAME);
}
$myip = ($this->permission_level & PRIVILEGE_ADMIN) ? \Opcenter\Net\Ip4::my_ip() : $this->dns_get_public_ip();
foreach ((array)$cnames as $c) {
$isWildcard = false;
if (!is_string($c)) {
error('Skipping garbled input - hostname not presented as string, is %s', gettype($c));
}
if (0 === strncmp($c, '*.', 2)) {
$c = substr($c, 2);
$isWildcard = true;
}
$c = $this->web_split_host($c);
$domain = $c['domain'];
$subdomain = $c['subdomain'];
if (!$this->web_domain_exists($domain)) {
error("cannot register lets encrypt: domain `%s' not a valid domain on this account",
$domain);
if ($strict) {
return false;
}
continue;
}
$host = ltrim($subdomain . '.' . $domain, '.');
if (!preg_match(Regex::HTTP_HOST, $host)) {
error("invalid server name `%s' specified", $c);
if ($strict) {
return false;
}
continue;
}
if (LETSENCRYPT_VERIFY_IP && $verifyip && self::INCLUDE_ALT_FORM && !$isWildcard) {
$altform = null;
if (0 === strncmp($host, 'www.', 4)) {
$altform = 'www.' . $host;
} else {
$altform = substr($host, 4);
}
if ($this->_verifyIP($altform, (array)$myip)) {
$cnreq[] = $altform;
} else if ($strict) {
return error("Domain `%s' would be dropped from renewal", $altform);
} else {
info("skipping alternative hostname form `%s', IP does not resolve to `%s'",
$altform, $myip);
}
}
if (LETSENCRYPT_VERIFY_IP && $verifyip && !$this->_verifyIP($host, (array)$myip)) {
$msg = [
"hostname `%s' IP `%s' doesn't match hosting IP `%s', "
. '%s request',
$host,
$this->dns_gethostbyname_t($host, static::DNS_VERIFY_IP_TIMEOUT),
$myip,
$strict ? 'aborting' : 'skipping'
];
if ($strict) {
return error(...$msg);
}
warn(...$msg);
continue;
}
if ($isWildcard) {
$host = '*.' . $host;
}
$cnreq[] = $host;
}
if (!$cnreq) {
error('no hostnames to register');
return null;
}
$storageMarker = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
if (! ($ret = $this->requestReal($cnreq, $storageMarker, $strict)) ) {
return $ret;
}
if ($strict && ($hosts = $this->filterMissingHostnames($cnames))) {
return error('Failed to append hostnames. Hostnames missing from new certificate: %s',
implode(', ', $hosts)
);
}
info(':letsencrypt_issuance_limit',
'reminder: only 5 duplicate certificates and ' .
'50 unique certificates may be issued per week per account'
);
return $this->_moveCertificates($storageMarker);
}
private function _verifyIP($hostname, array $myip)
{
$ip = null;
for ($i = 0; $i < 2; $i++) {
if ($ip = $this->dns_gethostbyname_t($hostname, static::DNS_VERIFY_IP_TIMEOUT)) {
break;
}
warn('DNS resolver failed to return answer in %dms', static::DNS_VERIFY_IP_TIMEOUT);
usleep(500000);
}
if (!$ip) {
return false;
}
foreach ($myip as $chkip) {
if ($chkip === $ip) {
return true;
}
}
return false;
}
private function _moveCertificates($site)
{
if ($site === LetsencryptAlias::SYSCERT_NAME) {
return $this->installSystemCertificate();
}
$files = LetsencryptAlias::getCertificateComponentData($site);
if (!$files) {
return false;
}
return $this->ssl_install($files['key'], $files['crt'], $files['chain']);
}
public function is_ca($crt)
{
$cert = $this->ssl_parse_certificate($crt);
if (!$cert) {
return error('invalid ssl certificate');
}
if (!isset($cert['extensions']['authorityKeyIdentifier'])) {
return false;
}
$authority = $cert['extensions']['authorityKeyIdentifier'];
$prefix = 'keyid:';
if (!strncmp($authority, $prefix, strlen($prefix))) {
$authority = substr($authority, strlen($prefix));
}
$authority = trim($authority);
return in_array($authority, self::LE_AUTHORITY_FINGERPRINT, true) ||
in_array($authority, self::LE_STAGING_AUTHORITY_FINGERPRINT, true);
}
public function append($cnames, bool $verifyip = null): bool
{
if (null === $verifyip) {
$verifyip = (bool)array_get(\Preferences::factory($this->getAuthContext()),
LetsencryptAlias\Preferences::VERIFY_IP, true);
}
$cnames = array_flip((array)$cnames);
$old = (array)$this->getSanFromCertificate();
$new = self::filterDomainSet(array_keys($cnames + array_flip($old)));
if (!array_diff($new, $old)) {
return true;
}
return (bool)$this->request($new, $verifyip, true);
}
public function revoke(): bool
{
if (!IS_CLI) {
return $this->query('letsencrypt_revoke');
}
if (!$this->certificateIssued()) {
return error('no certificate issued to revoke');
}
$cert = ($this->permission_level & PRIVILEGE_ADMIN) ? LetsencryptAlias::SYSCERT_NAME : $this->site;
$ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$cert, $this->activeServer])
->revoke();
if (!$ret) {
return error('revocation failed');
}
$this->_deleteAcmeCertificate($cert);
return true;
}
private function _deleteAcmeCertificate($account)
{
$acmeDir = LetsencryptAlias::acmeSiteStorageDirectory($account);
if (!file_exists($acmeDir)) {
return;
}
$dir = opendir($acmeDir);
while (false !== ($f = readdir($dir))) {
if ($f === '..' || $f === '.') {
continue;
}
unlink($acmeDir . '/' . $f);
}
closedir($dir);
rmdir($acmeDir);
return;
}
public function exists()
{
$path = LetsencryptAlias::acmeSiteStorageDirectory($this->site);
return file_exists($path);
}
public function storage_path(string $site): string
{
return LetsencryptAlias::acmeSiteStorageDirectory($site);
}
public function bootstrap(?int $attempt = null): ?bool
{
if ($attempt !== null && ($attempt > 10 || $attempt < 0)) {
return error('Invalid attempt count provided: %d', $attempt);
}
$domains = array_keys($this->web_list_domains());
$domains += append_config(array_map(
static function ($domain) {
return "*.${domain}";
}, $domains)
);
$domains = Letsencrypt::filterDomainSet($domains);
if (\count($domains) > 100) {
warn('Hostname count exceeds 100 (%d hostnames). Taking first 100 hostnames', \count($domains));
$domains = array_slice($domains, 0, 100);
}
if ($attempt !== null && $this->request($domains, true)) {
return true;
}
$delay = 43200;
if ($attempt === null) {
$attempt = static::BOOTSTRAP_ATTEMPTS;
$delay = 0;
} else if (--$attempt < 0) {
return error('Failed to bootstrap SSL');
}
$er = \Error_Reporter::flush_buffer();
$job = \Lararia\Jobs\Job::create(
\Lararia\Jobs\SimpleCommandJob::class,
$this->getAuthContext(),
'letsencrypt_bootstrap',
$attempt
);
$job->setTags([$this->site, 'letsencrypt_bootstrap']);
$job->delayedDispatch($delay);
\Error_Reporter::merge_buffer($er);
info('Scheduled letsencrypt:bootstrap job');
return null;
}
public function _housekeeping()
{
if (!$this->supported()) {
return;
}
if (!$this->_registered() && !$this->_register()) {
return error("failed to register with Let's Encrypt");
}
$this->renew_expiring();
}
private function _registered()
{
$key = str_replace(['/'], '.', $this->activeServer) . '.pem';
$storageDir = LetsencryptAlias::acmeDataDirectory();
return file_exists($storageDir . '/accounts/' . $key);
}
private function _register($email = null)
{
$acctdir = LetsencryptAlias::acmeDataDirectory();
if (!file_exists($acctdir)) {
mkdir($acctdir, 0700, true);
}
$email = $this->admin_get_email() ?: \Crm_Module::FROM_ADDRESS;
if (!$email) {
return error("Cannot register Let's Encrypt without an email address. Run 'cpcmd common_set_email newemail' from command-line.");
}
$marker = $this->site ?? Opcenter\Crypto\Letsencrypt::SYSCERT_NAME;
$ret = LetsencryptAlias\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$marker, $this->activeServer])
->register($email);
if (!$ret) {
return error("Let's Encrypt registration failed");
}
return true;
}
public function _edit()
{
$conf_new = $this->getAuthContext()->getAccount()->new;
$conf_cur = $this->getAuthContext()->getAccount()->old;
$ssl = \Opcenter\SiteConfiguration::getModuleRemap('openssl');
if (!$conf_new[$ssl]['enabled']) {
$this->_delete();
}
}
public function _delete()
{
$this->_deleteAcmeCertificate($this->site);
}
public function _verify_conf(\Opcenter\Service\ConfigurationContext $ctx): bool
{
return true;
}
public function _create()
{
return;
}
public function _create_user(string $user)
{
return;
}
public function _delete_user(string $user)
{
return;
}
public function _edit_user(string $userold, string $usernew, array $oldpwd)
{
return;
}
}