/ src / library / CertInstaller.php
<?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;
    }
}
X