/ src / library / media_jobs / AcmeRenewJob.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\media_jobs;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\AcmeManager;

/**
 * MediaJob that keeps the secure server's certificate current by
 * renewing it through ACME before it expires. It runs only on the
 * name server, in nondistributed mode, the same way MailCloneJob and
 * PodcastDownloadJob do: the work is a handful of network calls to
 * the certificate authority and a write to the live certificate
 * files, neither of which benefits from sharding across MediaUpdater
 * clients.
 *
 * The decision of when to renew follows the certificate authority's
 * ACME Renewal Information (RFC 9773): each due check asks the
 * authority for the certificate's suggested renewal window and
 * renews once the current time is inside it. When the authority
 * publishes no renewal information the job falls back to the
 * certificate's own expiry and renews when it is within one month of
 * expiring. Either way the renewal itself is the same path
 * AcmeTool's renew-now verb takes: AcmeManager::obtainCertificate
 * against the production directory, writing the live certificate
 * files.
 *
 * The job throttles itself so it does not query the authority on
 * every MediaUpdater tick. The unix time of the next permitted check
 * is kept in a single .date file under WORK_DIRECTORY/acme; each
 * check pushes that time forward by the authority's Retry-After when
 * one is given, or by one day otherwise, and a failed renewal backs
 * off by only an hour so a transient failure retries soon.
 *
 * Installing the renewed certificate writes the live files; the
 * running secure server picks them up on its own certificate check
 * and reloads its listener context in place, so this job does not
 * touch the server process directly (and could not: it runs in the
 * MediaUpdater process, not the server's).
 */
class AcmeRenewJob extends MediaJob
{
    /**
     * Production ACME directory. Renewal always issues trusted
     * certificates, so unlike AcmeTool's smoke verb there is no
     * staging option here.
     */
    const PRODUCTION_DIRECTORY =
        "https://acme-v02.api.letsencrypt.org/directory";
    /**
     * Path to the .date file holding the unix time of the next
     * permitted renewal-information check. Set in init().
     * @var string
     */
    private $check_file;
    /**
     * Sets the name-server-only mode and resolves the throttle file
     * path.
     */
    public function init()
    {
        $this->name_server_does_client_tasks_only = true;
        $this->check_file = self::checkFilePath();
    }
    /**
     * Returns the path of the .date file holding the unix time of
     * the next permitted renewal-information check.
     *
     * @return string the throttle file path
     */
    private static function checkFilePath()
    {
        return C\WORK_DIRECTORY . "/acme/renew-check.date";
    }
    /**
     * Asks the job to perform its renewal check on its next tick
     * rather than waiting out the current throttle, by removing the
     * throttle file. Server Settings calls this when a changed
     * secure domain list is saved with ACME on, so a certificate
     * covering the new list is obtained promptly: the renewal
     * decision notices the certificate no longer matches the
     * configured domains. Safe to call when no throttle file
     * exists.
     */
    public static function requestImmediateCheck()
    {
        $check_file = self::checkFilePath();
        if (file_exists($check_file)) {
            unlink($check_file);
        }
    }
    /**
     * Lets the job sit idle cheaply unless a renewal check is both
     * possible and due: ACME must be on, secure domains configured,
     * a live certificate present to renew, and the throttle's next
     * permitted check time reached. A skipped tick reports no work
     * so MediaUpdater can lengthen its sleep.
     *
     * @return bool whether nondistributedTasks should run this tick
     */
    public function checkPrerequisites()
    {
        if (!C\p('ACME_ON')) {
            $this->worked_this_tick = false;
            return false;
        }
        if (trim((string)C\p('SECURE_DOMAINS')) === "") {
            $this->worked_this_tick = false;
            return false;
        }
        if (!file_exists(C\SECURE_CERT_FILE)) {
            $this->worked_this_tick = false;
            return false;
        }
        if (time() < $this->readNextCheck()) {
            $this->worked_this_tick = false;
            return false;
        }
        return true;
    }
    /**
     * Asks the certificate authority whether the live certificate is
     * due for renewal and, when it is, renews it into the live
     * certificate files. Advances the throttle by the authority's
     * Retry-After (or one day) on a check, and by only an hour after
     * a failed renewal so a transient failure retries soon.
     */
    public function nondistributedTasks()
    {
        $now = time();
        $domains = $this->domains();
        if (empty($domains)) {
            L\crawlLog("AcmeRenew: SECURE_DOMAINS holds no usable " .
                "domain; nothing to renew.");
            $this->writeNextCheck($now + C\ONE_DAY);
            return;
        }
        $cert_pem = file_get_contents(C\SECURE_CERT_FILE);
        $manager = new AcmeManager(self::PRODUCTION_DIRECTORY,
            $this->contactEmail($domains));
        $window = $manager->renewalWindow($cert_pem);
        $next_check = $now + C\ONE_DAY;
        if ($window !== null && !empty($window["retry_after"])) {
            $next_check = $now + $window["retry_after"];
        }
        if (!$this->shouldRenew($window, $cert_pem, $now)) {
            $this->writeNextCheck($next_check);
            L\crawlLog("AcmeRenew: certificate not yet due for " .
                "renewal.");
            return;
        }
        L\crawlLog("AcmeRenew: certificate due for renewal; " .
            "obtaining a new one for " . implode(", ", $domains) .
            ".");
        if ($manager->obtainCertificate($domains)) {
            L\crawlLog("AcmeRenew: renewed and installed the live " .
                "certificate. The secure server reloads it on its " .
                "next certificate check.");
            $this->writeNextCheck($now + C\ONE_DAY);
        } else {
            L\crawlLog("AcmeRenew: renewal failed; will retry " .
                "within the hour. See the step log above for the " .
                "failing step.");
            $this->writeNextCheck($now + C\ONE_HOUR);
        }
    }
    /**
     * Decides whether the live certificate should be renewed now.
     * Always renews when the live certificate is the temporary
     * self-signed bootstrap placeholder, so a fresh install moves
     * from the placeholder to a real certificate on the first check.
     * Otherwise renews when the certificate's domains no longer
     * match the configured domain list (the list changed since the
     * certificate was issued). Failing that, with a published
     * renewal window, renews once the current time is inside it;
     * without one, falls back to the certificate's own expiry and
     * renews when it is within one month of expiring.
     *
     * @param array|null $window the authority's suggested window as
     *      returned by AcmeManager::renewalWindow, or null
     * @param string $cert_pem the installed certificate in PEM form
     * @param int $now the current unix time
     * @return bool whether to renew on this check
     */
    private function shouldRenew($window, $cert_pem, $now)
    {
        if ($this->isSelfSigned($cert_pem)) {
            L\crawlLog("AcmeRenew: live certificate is the " .
                "self-signed bootstrap placeholder; obtaining a real " .
                "certificate to replace it.");
            return true;
        }
        if (!$this->certificateCoversDomains($cert_pem)) {
            L\crawlLog("AcmeRenew: certificate domains differ from " .
                "the configured domain list; renewing for the new " .
                "list.");
            return true;
        }
        if ($window !== null) {
            return $now >= $window["start"];
        }
        $parsed = openssl_x509_parse($cert_pem);
        if ($parsed === false || !isset($parsed["validTo_time_t"])) {
            return false;
        }
        return ($parsed["validTo_time_t"] - $now) <= C\ONE_MONTH;
    }
    /**
     * Reports whether the live certificate is the temporary
     * self-signed bootstrap placeholder rather than one issued by a
     * certificate authority. The bootstrap names itself as its own
     * issuer, so a self-signed certificate has matching issuer and
     * subject names, while a certificate from an authority is issued
     * by a different name and does not. The secure server writes the
     * placeholder so port 443 can bind before a real certificate
     * exists; reporting it as self-signed is what lets the renewal
     * check replace it with a real one.
     *
     * @param string $cert_pem the installed certificate in PEM form
     * @return bool true when the certificate is self-signed
     */
    private function isSelfSigned($cert_pem)
    {
        $parsed = openssl_x509_parse($cert_pem);
        if ($parsed === false || empty($parsed["issuer"]) ||
            empty($parsed["subject"])) {
            return false;
        }
        return $parsed["issuer"] == $parsed["subject"];
    }
    /**
     * Checks whether the live certificate covers exactly the
     * configured domains, comparing the certificate's DNS subject
     * alternative names against SECURE_DOMAINS as case-insensitive
     * sets. A certificate that cannot be parsed or that carries no
     * DNS names does not cover anything, so a renewal replaces it.
     *
     * @param string $cert_pem the installed certificate in PEM form
     * @return bool whether the certificate matches the configured
     *      domain list
     */
    private function certificateCoversDomains($cert_pem)
    {
        $configured = array_values(array_unique(array_map(
            'strtolower', $this->domains())));
        sort($configured);
        $covered = $this->certificateDomains($cert_pem);
        return ($configured == $covered);
    }
    /**
     * Extracts the DNS names a certificate covers from its subject
     * alternative name extension, lowercased, de-duplicated, and
     * sorted so the list can be compared against the configured
     * domains as a set.
     *
     * @param string $cert_pem the certificate in PEM form
     * @return array sorted lowercased DNS names, empty when the
     *      certificate cannot be parsed or names none
     */
    private function certificateDomains($cert_pem)
    {
        $parsed = openssl_x509_parse($cert_pem);
        if ($parsed === false ||
            empty($parsed['extensions']['subjectAltName'])) {
            return [];
        }
        $names = [];
        $alt_entries = explode(',',
            $parsed['extensions']['subjectAltName']);
        foreach ($alt_entries as $alt_entry) {
            $alt_entry = trim($alt_entry);
            if (stripos($alt_entry, 'DNS:') === 0) {
                $names[] = strtolower(substr($alt_entry,
                    strlen('DNS:')));
            }
        }
        $names = array_values(array_unique($names));
        sort($names);
        return $names;
    }
    /**
     * Resolves the certificate domains from the configured
     * SECURE_DOMAINS, splitting on commas and trimming each name.
     *
     * @return array the configured domains, possibly empty
     */
    private function domains()
    {
        $configured = trim((string)C\p('SECURE_DOMAINS'));
        if ($configured === "") {
            return [];
        }
        return array_values(array_filter(array_map('trim',
            explode(',', $configured))));
    }
    /**
     * Builds the contact email for the ACME account from the
     * principal domain, matching AcmeTool's default. Renewal keys
     * the account by its key rather than this address, so it serves
     * only as the registered contact.
     *
     * @param array $domains the certificate domains, principal first
     * @return string the contact email
     */
    private function contactEmail($domains)
    {
        return "admin@" . $domains[0];
    }
    /**
     * Reads the unix time of the next permitted renewal check from
     * the throttle file, or 0 when the file is absent so the first
     * enabled tick checks immediately.
     *
     * @return int unix time of the next permitted check
     */
    private function readNextCheck()
    {
        if (!file_exists($this->check_file)) {
            return 0;
        }
        return (int)file_get_contents($this->check_file);
    }
    /**
     * Records the unix time of the next permitted renewal check,
     * creating the containing directory when it does not yet exist.
     *
     * @param int $when unix time of the next permitted check
     */
    private function writeNextCheck($when)
    {
        $directory = dirname($this->check_file);
        if (!is_dir($directory)) {
            mkdir($directory, 0700, true);
        }
        file_put_contents($this->check_file, (string)$when);
    }
}
X