<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\library;
use seekquarry\yioop\configs as C;
/**
* Installs a renewed TLS certificate and makes the running secure
* server present it without a restart.
*
* Two steps, in this order so a TLS handshake never reads a
* half-written certificate. First the certificate chain and private
* key are written atomically: each is written to a temporary file
* in the same directory and renamed into place, which is atomic on
* the local filesystems Yioop runs on, so a concurrent handshake
* sees either the old file or the new one but never a partial one.
* The private key is created mode 0600. Only after both files are
* in place is the live listener told to use them.
*
* The reload updates local_cert and local_pk on the running
* listener's stream context with stream_context_set_option. Because
* the certificate is read at handshake time, subsequent handshakes
* present the new certificate with no socket recreated and no
* connection dropped, and -- the point that makes this compatible
* with dropping to a non-root user after binding -- no privileged
* port is rebound. This in-place reload was confirmed on the
* deployment's PHP and OpenSSL build before being relied on here.
*
* @author Chris Pollett chris@pollett.org
*/
class CertInstaller
{
/**
* Absolute path the certificate chain (PEM) is written to and
* the secure listener reads as local_cert.
* @var string
*/
private $cert_path;
/**
* Absolute path the private key (PEM) is written to and the
* secure listener reads as local_pk.
* @var string
*/
private $key_path;
/**
* @param string $cert_path destination for the certificate
* chain; defaults to the configured SECURE_CERT_FILE
* @param string $key_path destination for the private key;
* defaults to the configured SECURE_KEY_FILE
*/
public function __construct($cert_path = null, $key_path = null)
{
$this->cert_path = ($cert_path === null)
? C\SECURE_CERT_FILE : $cert_path;
$this->key_path = ($key_path === null)
? C\SECURE_KEY_FILE : $key_path;
}
/**
* Writes $data to $path atomically: to a temporary file in the
* same directory, then renamed over $path. Returns false if the
* directory is missing or any step fails. The temporary file
* gets $mode before the rename so the destination never briefly
* exists with looser permissions.
*
* @param string $path destination path
* @param string $data file contents
* @param int $mode permission bits for the file
* @return bool true on success
*/
private function atomicWrite($path, $data, $mode)
{
$dir = dirname($path);
if (!is_dir($dir)) {
if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
return false;
}
}
$temp = @tempnam($dir, "cert");
if ($temp === false) {
return false;
}
if (@file_put_contents($temp, $data) === false) {
@unlink($temp);
return false;
}
@chmod($temp, $mode);
if (!@rename($temp, $path)) {
@unlink($temp);
return false;
}
return true;
}
/**
* Writes the certificate chain and private key to their
* configured paths atomically, key first to 0600 then chain to
* 0644. On any write failure the install is reported failed and
* the live listener is not touched, so a botched write cannot
* make the server present a broken certificate.
*
* @param string $fullchain_pem certificate chain in PEM form
* (leaf first, then intermediates)
* @param string $key_pem private key in PEM form
* @return bool true if both files were written
*/
public function writeFiles($fullchain_pem, $key_pem)
{
if (!$this->atomicWrite($this->key_path, $key_pem, 0600)) {
return false;
}
if (!$this->atomicWrite($this->cert_path, $fullchain_pem,
0644)) {
return false;
}
return true;
}
/**
* Updates the secure listener's stream context so subsequent
* TLS handshakes present the newly written certificate. Scans
* the server's listeners for the secure one and sets local_cert
* and local_pk on its socket context. Returns the number of
* secure listeners updated (normally one); zero means no secure
* listener was found, in which case the new files are still on
* disk and will be used the next time the server starts.
*
* @param object $web_site the running WebSite whose listeners()
* expose the live server sockets
* @return int count of secure listeners reloaded
*/
public function reloadListener($web_site)
{
if ($web_site === null ||
!method_exists($web_site, "listeners")) {
return 0;
}
$reloaded = 0;
foreach ($web_site->listeners() as $listener) {
if (empty($listener->is_secure)) {
continue;
}
$socket = $listener->resource();
if (!is_resource($socket)) {
continue;
}
stream_context_set_option($socket, "ssl", "local_cert",
$this->cert_path);
stream_context_set_option($socket, "ssl", "local_pk",
$this->key_path);
$reloaded++;
}
return $reloaded;
}
/**
* Full install: write the files atomically, then reload the
* live listener. The reload only runs if the write succeeded,
* so a failed write leaves the running server on its old
* certificate. When no WebSite is supplied (for example when
* issuance runs as a separate process from the server) the
* files are written and the server picks them up on its next
* start or its next reload.
*
* @param string $fullchain_pem certificate chain in PEM form
* @param string $key_pem private key in PEM form
* @param object $web_site optional running WebSite to reload in
* place
* @return bool true if the files were written (whether or not a
* listener was reloaded)
*/
public function install($fullchain_pem, $key_pem,
$web_site = null)
{
if (!$this->writeFiles($fullchain_pem, $key_pem)) {
return false;
}
if ($web_site !== null) {
$this->reloadListener($web_site);
}
return true;
}
}