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:
<?php
namespace Module\Support;
use Crm_Module;
use File_Module;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\RequestOptions;
use HTTP\SelfReferential;
use Letsencrypt_Module;
use Mail;
use Module_Skeleton;
use Opcenter\Account\State;
use Opcenter\Crypto\Ssl;
use Opcenter\Filesystem;
use Opcenter\Http\Apnscp;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\UriInterface;
use Regex;
use Util_Account_Hooks;
use Util_Process;
use Opcenter\Crypto\Letsencrypt as LEService;
abstract class Letsencrypt extends Module_Skeleton
{
const ACME_WORKDIR = '/tmp/acme';
const ACME_URI_PREFIX = '/.well-known/acme-challenge';
const ACME_CERTIFICATE_BASE = '/storage/certificates';
const SKIP_IP_PREFERENCE = LEService\Preferences::VERIFY_IP;
protected function renewExpiringCertificates()
{
$certs = (array)$this->_findCertificates();
if ($this->certificateIssued(LEService::SYSCERT_NAME)) {
$certs[] = LEService::SYSCERT_NAME;
}
foreach ($certs as $c) {
$path = LEService::acmeSiteStorageDirectory($c) . '/cert.pem';
if (!file_exists($path)) {
continue;
}
$crt = file_get_contents($path);
$x509 = $this->ssl_parse_certificate($crt);
if (!$x509 || !LEService::expiring($x509)) {
continue;
}
if (LETSENCRYPT_DEBUG) {
warn("%s is eligible for renewal but %s is in Let's Encrypt testing mode. " .
'Disable with %s then re-run to renew certificate.',
$c,
PANEL_BRAND,
'cpcmd scope:set ssl.debug false'
);
continue;
}
if (!$this->_renew($c, $x509)) {
dlog("Failed renewal on $c");
}
}
}
private function _findCertificates()
{
$datadir = LEService::acmeSiteStorageDirectory('');
if (!file_exists($datadir)) {
return true;
}
$dh = opendir($datadir);
if (!$dh) {
return null;
}
$certs = array();
while (false !== ($entry = readdir($dh))) {
if (0 !== strncmp($entry, 'site', 4)) {
continue;
}
if (!\Auth::get_domain_from_site_id(substr($entry, 4))) {
warn("Orphaned certificate located for removed site `%s'", $entry);
continue;
}
if (State::disabled($entry)) {
debug("Site %s disabled - skipping", $entry);
continue;
}
$certs[] = $entry;
}
return $certs;
}
protected function acmeSiteStorageDirectory($host): string
{
return LEService::acmeDataDirectory() . '/certs/' .
$this->canonicalizeServer($this->activeServer) . '/' . $host;
}
protected function certificateIssued($account = null)
{
if (!$account) {
$account = $this->permission_level & PRIVILEGE_ADMIN ?
LEService::SYSCERT_NAME : $this->site;
}
$prefix = LEService::acmeSiteStorageDirectory($account);
return file_exists($prefix) && file_exists($prefix . '/cert.pem');
}
protected function _renew($site, $x509)
{
\Error_Reporter::flush_buffer();
if ($site !== LEService::SYSCERT_NAME && State::disabled($site)) {
return info("Site `%s' disabled, bypassing renewal", $site);
}
$ctx = substr($site, 0, 4) === 'site' ? \Auth::context(null, $site) : \Auth::context(null);
$verifyip = data_get(
\Preferences::factory($ctx),
self::SKIP_IP_PREFERENCE,
true
);
if ($site === LEService::SYSCERT_NAME) {
$ret = $this->_renewSystemCertificate($x509);
} else {
$afi = \apnscpFunctionInterceptor::factory($ctx);
if ($ret = $afi->letsencrypt_renew($verifyip)) {
$this->checkPhantomCertificate($afi);
}
}
if ($ret || !LETSENCRYPT_NOTIFY_FAILURE || LEService::daysUntilExpiry($x509) > 5) {
return $ret;
}
$cmd = 'cpcmd ';
if ($site !== LEService::SYSCERT_NAME) {
$cmd .= "-d ${site} ";
}
$cmd .= 'letsencrypt:renew ' . ($verifyip ? 'true' : 'false');
Mail::send(
Crm_Module::COPY_ADMIN,
'Automated renewal failed on ' . $site,
"renew manually $site - " . SERVER_NAME . "\n" .
$cmd . "\n" .
"\n\nError log:" . var_export(\Error_Reporter::flush_buffer(), true)
);
return false;
}
protected function checkPhantomCertificate(\apnscpFunctionInterceptor $afi)
{
$crt = $afi->ssl_get_certificate();
$key = $afi->ssl_get_private_key();
if ($afi->ssl_valid($crt, $key)) {
return;
}
$site = 'site' . \Auth::get_site_id_from_domain(
$afi->common_get_service_value('siteinfo', 'domain')
);
Mail::send(
Crm_Module::COPY_ADMIN,
SERVER_NAME_SHORT . ': Phantom cert detected on ' . $site,
$crt . "\n" . $key . "\n\nError log:" . var_export(\Error_Reporter::flush_buffer(), true)
);
}
protected function _renewSystemCertificate($x509)
{
$cns = $this->ssl_get_alternative_names($x509);
if (!$this->requestReal($cns, LEService::SYSCERT_NAME)) {
return false;
}
return $this->installSystemCertificate();
}
protected function requestReal(array $domains, $site, bool $strict = false)
{
$domains = self::filterDomainSet($domains);
if (!file_exists(self::ACME_WORKDIR)) {
Filesystem::mkdir(self::ACME_WORKDIR, WS_USER, 0, 0711);
}
for ($i = 0, $n = \count($domains); $i < $n; $i++) {
if (!isset($domains[$i])) {
continue;
}
$domain = $domains[$i];
if (false !== strpos($domain, '*.')) {
continue;
}
}
if (!\count($domains)) {
return null;
} else if (\count($domains) > 100) {
return error('only 100 hostnames may be included in a certificate');
}
$dispatch = LEService\AcmeDispatcher::instantiateContexted($this->getAuthContext(), [$site]);
$dispatch->setStrictMode($strict);
$ret = $dispatch->issue($domains);
if ($ret && 0 === getmyuid()) {
Util_Process::exec('/bin/chown -hR %(user)s %(path)s',
[
'user' => File_Module::UPLOAD_UID,
'path' => LEService::acmeSiteStorageDirectory($site)
]
);
}
return $ret ? $domains : false;
}
public static function filterDomainSet(array $domains): array
{
$wildcards = [];
$domains = array_filter(array_unique($domains), static function ($domain) use (&$wildcards) {
if (0 === strncmp($domain, '*.', 2)) {
$wildcards[] = $domain;
return false;
}
return true;
});
return array_filter($domains, static function ($domain) use ($wildcards) {
foreach ($wildcards as $wc) {
if (fnmatch($wc, $domain)) {
return false;
}
}
return true;
}) + append_config($wildcards);
}
protected function isReachable($domain)
{
return LEService\Solvers\Http::instantiateContexted($this->getAuthContext(), ['a', 'b'])->reachable($domain);
}
protected function installSystemCertificate()
{
$dest = Ssl::systemCertificatePath();
$src = LEService::acmeSiteStorageDirectory(LEService::SYSCERT_NAME);
$composite = '';
foreach (array('key.pem', 'fullchain.pem') as $c) {
$path = $src . DIRECTORY_SEPARATOR . $c;
if (!file_exists($path)) {
return false;
}
$composite .= file_get_contents($path) . "\n";
}
if (!file_put_contents($dest, $composite)) {
return error("failed to store certificate contents in `%s'", $dest);
}
chmod($dest, 0600);
Util_Account_Hooks::instantiateContexted($this->getAuthContext())->run('reload', [\Ssl_Module::SYS_RHOOK]);
Apnscp::reload();
return true;
}
protected function getSanFromCertificate(string $cert = null): ?array
{
if (!$cert && $this->permission_level & PRIVILEGE_ADMIN) {
$cert = LEService::SYSCERT_NAME;
}
if (!$cert) {
if (!$cert = $this->ssl_get_certificates()) {
return null;
}
$cert = array_pop($cert)['crt'];
$cert = $this->ssl_get_certificate($cert);
} else if (is_dir($path = LEService::acmeSiteStorageDirectory($cert))) {
$cert = LEService::getCertificateComponentData($cert)['crt'];
} else {
return null;
}
$crt = $this->ssl_parse_certificate($cert);
if (!$this->is_ca($crt)) {
return null;
}
return (array)$this->ssl_get_alternative_names($crt);
}
protected function systemNeedsIssuance(): bool
{
if (!$this->certificateIssued(LEService::SYSCERT_NAME)) {
return true;
}
$cns = array_filter([gethostname()] + LETSENCRYPT_ADDITIONAL_CERTS);
if (null === ($cert = LEService::getCertificateComponentData(LEService::SYSCERT_NAME))) {
return true;
}
$sans = $this->ssl_get_alternative_names($cert['crt']);
foreach (array_diff($cns, $sans) as $c) {
if (!$this->isReachable($c)) {
warn("Hostname `%s' requested in system certificate ([letsencrypt] => additional_certs), " .
'but unreachable - ignoring',
$c
);
continue;
}
return true;
}
return false;
}
protected function filterMissingHostnames(array $cnames): array {
$new = array_flip((array)$this->getSanFromCertificate($this->site));
$missing = array_diff_key(array_flip(Letsencrypt::filterDomainSet($cnames)), $new);
return array_keys($missing);
}
}