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:
<?php
abstract class Module_Support_Letsencrypt extends Module_Skeleton
{
const MAX_EXPIRY_DAYS = LETSENCRYPT_LOOKAHEAD_DAYS;
const MIN_EXPIRY_DAYS = LETSENCRYPT_LOOKBEHIND_DAYS;
const ACME_WORKDIR = '/tmp/acme';
const ACME_URI_PREFIX = '/.well-known';
const SYSTEM_CERT_PATH = '/etc/pki/tls/certs';
const ACME_CERTIFICATE_BASE = '/storage/certificates';
const SYSCERT_NAME = 'MAIN';
const SKIP_IP_PREFERENCE = 'letsencrypt.verifyip';
protected $acmeClientDirectory;
public function serverBootstrapped(bool $verify_peer = true): bool
{
$context = stream_context_create(
[
'http' => [
'method' => 'GET',
'timeout' => 5,
'protocol_version' => 1.1
],
'ssl' => [
'verify_peer' => $verify_peer
]
]
);
$try = silence(function () use ($context) {
return file_get_contents('https://' . SERVER_NAME, false, $context);
});
return $try !== false;
}
protected function canonicalizeServer(string $server): string
{
return str_replace('/', '.', $server);
}
protected function acmeDirectory(): string
{
return realpath(INCLUDE_PATH . self::ACME_CERTIFICATE_BASE);
}
protected function acmeDataDirectory(): string
{
return $this->acmeDirectory() . '/data';
}
protected function acmeSiteStorageDirectory($host): string
{
return $this->acmeDataDirectory() . '/certs/' .
$this->canonicalizeServer($this->activeServer) . '/' . $host;
}
protected function getAcmeClientDirectory(): string
{
if (null !== $this->acmeClientDirectory) {
return $this->acmeClientDirectory;
}
$rfx = new ReflectionClass(Kelunik\AcmeClient\AcmeFactory::class);
$path = $rfx->getFileName();
do {
$path = dirname($path);
if (is_dir($path . '/bin')) {
break;
}
} while ($path);
$this->acmeClientDirectory = $path;
return $this->acmeClientDirectory;
}
protected function renewExpiringCertificates()
{
$certs = $this->_findCertificates();
if ($this->certificateIssued(static::SYSCERT_NAME)) {
$certs[] = static::SYSCERT_NAME;
}
$dt = new DateTime();
foreach ($certs as $c) {
$path = $this->acmeSiteStorageDirectory($c) . '/cert.pem';
if (!file_exists($path)) {
continue;
}
$crt = file_get_contents($path);
$x509 = $this->ssl_parse_certificate($crt);
if (!$x509 || !$this->_isExpiring($x509, $dt)) {
continue;
}
if (!$this->_renew($c, $x509)) {
$this->_notifyFailure($c);
}
}
}
protected function getSanFromCertificate(): ?array
{
$cert = $this->ssl_get_certificates();
if (!$cert) {
return [];
}
$cert = array_pop($cert);
$cert = $this->ssl_get_certificate($cert['crt']);
$crt = $this->ssl_parse_certificate($cert);
if (!$this->is_ca($crt)) {
return null;
}
return (array)$this->ssl_get_alternative_names($crt);
}
protected function certificateIssued($account = null)
{
if (!$account) {
$account = $this->site;
}
$prefix = $this->acmeSiteStorageDirectory($account);
return file_exists($prefix) && file_exists($prefix . '/cert.pem');
}
protected function _renew($site, $x509)
{
if ($site === Letsencrypt_Module::SYSCERT_NAME) {
$ret = $this->_renewSystemCertificate($x509);
} else {
if (\Opcenter\Account\State::disabled($site)) {
return info("Site `%s' disabled, bypassing renewal", $site);
}
$verifyip = data_get(
\Preferences::factory(\Auth::context(null, $site)),
self::SKIP_IP_PREFERENCE,
true
);
$ret = $this->pman_schedule_api_cmd_admin($site, null, 'letsencrypt_renew', [$verifyip]);
}
if (!$ret) {
Mail::send(
Crm_Module::COPY_ADMIN,
"Automated renewal failed on " . $site,
"renew manually $site - " . SERVER_NAME .
"\n\nError log:" . var_export(\Error_Reporter::get_buffer(), true)
);
}
return $ret;
}
protected function _renewSystemCertificate($x509)
{
$cns = $this->ssl_get_alternative_names($x509);
if (!$this->requestReal($cns, static::SYSCERT_NAME)) {
return false;
}
return $this->installSystemCertificate();
}
protected function installSystemCertificate()
{
$dest = $this->_getSystemCertPath();
$src = $this->acmeSiteStorageDirectory(static::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::run('reload', array('letsencrypt'));
\Opcenter\Http\Apnscp::reload();
return true;
}
protected function getCertificateComponentData($site)
{
$acmePrefix = $this->acmeSiteStorageDirectory($site);
$files = array(
'chain' => $acmePrefix . '/chain.pem',
'crt' => $acmePrefix . '/cert.pem',
'key' => $acmePrefix . '/key.pem'
);
foreach ($files as $k => $name) {
if (!file_exists($name)) {
return error("missing necessary LE component `%s'", basename($name));
}
$files[$k] = file_get_contents($name);
}
return $files;
}
protected function requestReal(array $domains, $site, bool $strict = false)
{
$domains = array_unique($domains);
if (!file_exists(self::ACME_WORKDIR)) {
mkdir(self::ACME_WORKDIR, 0711) && chown(self::ACME_WORKDIR, WS_USER);
}
for ($i = 0, $n = sizeof($domains); $i < $n; $i++) {
if (!isset($domains[$i])) {
continue;
}
$domain = $domains[$i];
if (!$this->isReachable($domain)) {
$msg = ["unreachable domain subrequest, removing `%s' from certificate", $domain];
if ($strict) {
return error(...$msg);
}
warn(...$msg);
unset($domains[$i]);
}
}
if (!count($domains)) {
return false;
} else if (count($domains) > 100) {
return error("only 100 hostnames may be included in a certificate");
}
$args = array(
'--domains' => join(":", $domains),
'--path' => self::ACME_WORKDIR,
'--user' => Web_Module::WEB_USERNAME,
'--storage' => $this->acmeDataDirectory(),
'-s' => $this->activeServer,
'--output' => $site
);
$ret = $this->_exec('issue', $args);
if ($ret && 0 === getmyuid()) {
Util_Process::exec('/bin/chown -R %(user)s %(path)s',
[
'user' => File_Module::UPLOAD_UID,
'path' => $this->acmeSiteStorageDirectory($site)
]
);
}
return !$ret ?: $domains;
}
protected function isReachable($domain)
{
$dir = self::ACME_WORKDIR . '/' . self::ACME_URI_PREFIX;
$path = $dir . '/' . uniqid("acme-test", true);
if (!is_dir($dir)) {
mkdir($dir, 0755, true);
}
$marker = uniqid('ssl-check', true);
file_put_contents($path, $marker);
if (!preg_match(Regex::DOMAIN, $domain)) {
return error("skipping SSL for domain `%s' - domain is not fully-qualified", $domain);
}
$url = 'http://' . $domain . '/' . ltrim(substr($path, strlen(self::ACME_WORKDIR)), '/');
$http = new HTTP_Request2(
$url
);
$http->setConfig([
'ssl_verify_peer' => false,
'ssl_verify_host' => false,
'follow_redirects' => true,
'max_redirects' => 2
]);
try {
$response = $http->send();
$code = $response->getStatus();
if (!preg_match('!^https?://' . preg_quote($domain, '!') .'(?::\d+)?/!i', $response->getEffectiveUrl())) {
return false;
}
switch ($code) {
case 200:
$contents = $response->getBody();
return trim(strip_tags($contents)) === $marker;
default:
return false;
}
} catch (\Exception $e) {
return false;
} finally {
file_exists($path) && unlink($path);
}
return false;
}
abstract protected function _exec($cmd, array $args);
private function _getSystemCertPath()
{
return self::SYSTEM_CERT_PATH . DIRECTORY_SEPARATOR . 'server.pem';
}
private function _notifyFailure($c)
{
$c .= var_export(Error_Reporter::flush_buffer(), true);
Mail::send(Crm_Module::COPY_ADMIN, "renewal failed", $c);
}
private function _findCertificates()
{
$datadir = $this->acmeSiteStorageDirectory("");
if (!file_exists($datadir)) {
return true;
}
$dh = opendir($datadir);
if (!$dh) {
return null;
}
$certs = array();
while (false !== ($entry = readdir($dh))) {
if (0 !== strpos($entry, 'site')) {
continue;
}
$certs[] = $entry;
}
return $certs;
}
private function _isExpiring($x509, DateTime $now)
{
$dt = new DateTime();
$dt->setTimestamp($x509['validTo_time_t']);
$days = $now->diff($dt)->days;
return $days <= self::MAX_EXPIRY_DAYS && $days >= self::MIN_EXPIRY_DAYS;
}
}