<?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);
}
}