<?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;
/**
* Orchestrates obtaining a TLS certificate over ACME for a list of
* domains, sequencing the pieces that each handle one concern:
* AcmeClient speaks the protocol, AcmeKeyStore holds the account
* key, AcmeChallengeStore publishes HTTP-01 tokens for the port-80
* route to serve, CsrBuilder makes the multi-SAN request, and
* CertInstaller writes the result and reloads the running server.
*
* The single entry point obtainCertificate runs the whole flow:
* load or create the account key, fetch the directory, register the
* account, open an order for the domains, answer each domain's
* HTTP-01 challenge by publishing its key authorization and telling
* the certificate authority to validate, wait for the order to be
* ready, finalize it with a freshly generated key and CSR, download
* the issued chain, and install it. Each published token is removed
* once the order leaves the pending state, whether issuance
* succeeded or failed, so stale tokens are not left served.
*
* It does not decide WHEN to renew; that is the renewal job's role,
* which uses ariCertId (computed here from an installed certificate)
* to ask the certificate authority for a renewal window.
*
* @author Chris Pollett chris@pollett.org
*/
class AcmeManager
{
/**
* ACME directory URL (the staging directory while testing, the
* production directory once trusted certificates are wanted).
* @var string
*/
private $directory_url;
/**
* Contact email registered with the ACME account.
* @var string
*/
private $contact_email;
/**
* Account key store.
* @var AcmeKeyStore
*/
private $key_store;
/**
* HTTP-01 challenge token store the port-80 route reads.
* @var AcmeChallengeStore
*/
private $challenge_store;
/**
* Certificate installer.
* @var CertInstaller
*/
private $cert_installer;
/**
* Running WebSite to reload after install, or null when
* issuance runs separately from the server.
* @var object|null
*/
private $web_site;
/**
* Published challenge tokens awaiting cleanup after an order
* leaves the pending state.
* @var array
*/
private $published_tokens = [];
/**
* @param string $directory_url ACME directory URL
* @param string $contact_email contact address for the account
* @param AcmeKeyStore $key_store account key store, or null for
* the default location
* @param AcmeChallengeStore $challenge_store challenge token
* store, or null for the default location
* @param CertInstaller $cert_installer installer, or null for
* the default certificate paths
* @param object $web_site running WebSite to reload, or null
*/
public function __construct($directory_url, $contact_email,
$key_store = null, $challenge_store = null,
$cert_installer = null, $web_site = null)
{
$this->directory_url = $directory_url;
$this->contact_email = $contact_email;
$this->key_store = ($key_store === null)
? new AcmeKeyStore() : $key_store;
$this->challenge_store = ($challenge_store === null)
? new AcmeChallengeStore() : $challenge_store;
$this->cert_installer = ($cert_installer === null)
? new CertInstaller() : $cert_installer;
$this->web_site = $web_site;
}
/**
* Obtains and installs a certificate covering $domains. Returns
* true when a certificate was issued and installed, false at the
* first step that fails. Logs progress and the failing step so a
* run can be followed in the server log.
*
* @param array $domains DNS names for the certificate; the first
* is the principal domain
* @return bool true on a fully successful issue and install
*/
public function obtainCertificate($domains)
{
if (empty($domains)) {
crawlLog("AcmeManager: no domains given; nothing to do.");
return false;
}
$account_key = $this->key_store->accountKey();
if ($account_key === null) {
crawlLog("AcmeManager: could not load or create the " .
"account key.");
return false;
}
$client = $this->makeClient($account_key);
$client->fetchDirectory();
if (!$client->registerAccount($this->contact_email)) {
crawlLog("AcmeManager: account registration failed.");
return false;
}
$order = $client->newOrder($domains);
if ($order === null || empty($order["authorizations"])) {
crawlLog("AcmeManager: could not create an order for " .
implode(", ", $domains) . ".");
return false;
}
$published = $this->publishChallenges($client, $order);
if ($published === null) {
$this->clearTokens();
return false;
}
$ready = $client->pollStatus($order["url"]);
$authorized = $ready !== null &&
($ready["status"] ?? "") === "ready";
$this->clearTokens();
if (!$authorized) {
$this->logAuthorizationFailures($client, $order);
crawlLog("AcmeManager: authorizations did not all " .
"validate; order not ready.");
return false;
}
return $this->finalizeAndInstall($client, $order, $domains);
}
/**
* Creates the protocol client for an issuance run. Isolated so
* the client construction has one home and so a test can supply
* a stand-in client without reaching the network.
*
* @param string $account_key the account key PEM
* @return AcmeClient the protocol client
*/
protected function makeClient($account_key)
{
return new AcmeClient($this->directory_url, $account_key);
}
/**
* For each authorization in the order, finds the HTTP-01
* challenge, publishes its key authorization to the challenge
* store, and tells the certificate authority it is ready.
* Returns the published token list, or null if any authorization
* lacked an HTTP-01 challenge.
*
* @param AcmeClient $client the protocol client
* @param array $order the order with authorization URLs
* @return array|null published tokens, or null on a missing
* challenge
*/
private function publishChallenges($client, $order)
{
$tokens = [];
foreach ($order["authorizations"] as $authz_url) {
$authorization = $client->authorization($authz_url);
$domain = $authorization["identifier"]["value"] ?? "?";
$challenge = $client->httpChallenge($authorization);
if ($challenge === null ||
empty($challenge["token"])) {
crawlLog("AcmeManager: authorization for " . $domain .
" had no http-01 challenge.");
return null;
}
$token = $challenge["token"];
$key_authorization = $client->keyAuthorization($token);
if (!$this->challenge_store->put($token,
$key_authorization)) {
crawlLog("AcmeManager: could not publish challenge " .
"token for " . $domain . ".");
return null;
}
$tokens[] = $token;
crawlLog("AcmeManager: published challenge for " .
$domain . " (token " . $token . "); the certificate " .
"authority will fetch /.well-known/acme-challenge/" .
$token . " over port 80.");
$notify = $client->notifyChallengeReady($challenge);
$notify_status = $notify["status"] ?? 0;
if ($notify_status < 200 || $notify_status >= 300) {
crawlLog("AcmeManager: the ready signal for " .
$domain . " was rejected by the certificate " .
"authority (HTTP " . $notify_status . "): " .
trim((string)($notify["body"] ?? "")) . " -- the " .
"authority will not validate this domain.");
}
}
$this->published_tokens = $tokens;
return $tokens;
}
/**
* Removes every published challenge token from the store.
*
* @return void
*/
private function clearTokens()
{
foreach ($this->published_tokens as $token) {
$this->challenge_store->clear($token);
}
$this->published_tokens = [];
}
/**
* Re-fetches each authorization after the order failed to reach
* ready and logs its status together with any error the
* certificate authority recorded against the http-01 challenge.
* This turns a generic "not ready" into a per-domain reason --
* for example a still-pending authorization (the authority never
* fetched the token) versus an invalid one (the authority
* fetched and rejected the response, with a connection or dns
* detail). Best-effort: a failure to fetch a status is itself
* logged and skipped.
*
* @param AcmeClient $client the protocol client
* @param array $order the order whose authorizations to inspect
* @return void
*/
private function logAuthorizationFailures($client, $order)
{
foreach ($order["authorizations"] as $authz_url) {
$authorization = $client->authorization($authz_url);
if (!is_array($authorization)) {
crawlLog("AcmeManager: could not read an " .
"authorization status from " . $authz_url . ".");
continue;
}
$domain = $authorization["identifier"]["value"] ?? "?";
$status = $authorization["status"] ?? "?";
$detail = "";
foreach ($authorization["challenges"] ?? [] as $one) {
if (($one["type"] ?? "") === "http-01" &&
!empty($one["error"])) {
$error = $one["error"];
$detail = " (" . ($error["type"] ?? "") . ": " .
($error["detail"] ?? "") . ")";
}
}
crawlLog("AcmeManager: authorization for " . $domain .
" is " . $status . $detail . ".");
}
}
/**
* Generates a key and CSR for the domains, finalizes the order,
* waits for issuance, downloads the chain, and installs it.
*
* @param AcmeClient $client the protocol client
* @param array $order the ready order
* @param array $domains the certificate domains
* @return bool true on a successful finalize, download, and
* install
*/
private function finalizeAndInstall($client, $order, $domains)
{
$csr = CsrBuilder::build($domains);
if ($csr === null) {
crawlLog("AcmeManager: could not build the CSR.");
return false;
}
$client->finalizeOrder($order, $csr["csr_der"]);
$issued = $client->pollStatus($order["url"]);
if ($issued === null ||
($issued["status"] ?? "") !== "valid") {
crawlLog("AcmeManager: order did not reach valid after " .
"finalize.");
return false;
}
if (isset($order["url"])) {
$issued["url"] = $order["url"];
}
$chain = $client->downloadCertificate($issued);
if ($chain === null || $chain === "") {
crawlLog("AcmeManager: certificate download returned " .
"nothing.");
return false;
}
if (!$this->cert_installer->install($chain, $csr["key_pem"],
$this->web_site)) {
crawlLog("AcmeManager: certificate install failed.");
return false;
}
crawlLog("AcmeManager: installed a certificate for " .
implode(", ", $domains) . ".");
return true;
}
/**
* Computes the ARI certificate identifier for an installed
* certificate so the renewal job can ask the certificate
* authority when to renew. A thin pass-through to CsrBuilder
* kept here so callers have one ACME entry point.
*
* @param string $cert_pem the installed certificate in PEM form
* @return string|null the ARI cert id, or null
*/
public function ariCertId($cert_pem)
{
return CsrBuilder::ariCertId($cert_pem);
}
/**
* Asks the certificate authority's ACME Renewal Information
* endpoint when an installed certificate should be renewed,
* returning the suggested window together with any Retry-After
* the authority asked the caller to wait before querying again.
* Lets the renewal job renew when the authority recommends
* rather than at a fixed age, and back off between polls as the
* authority asks. Returns null when the certificate identifier
* cannot be computed, the account key is unavailable, or the
* authority publishes no renewal information.
*
* @param string $cert_pem the installed certificate in PEM form
* @return array|null ['start' => int, 'end' => int,
* 'retry_after' => int] as unix timestamps and seconds, or
* null when no window is available
*/
public function renewalWindow($cert_pem)
{
$ari_cert_id = $this->ariCertId($cert_pem);
if ($ari_cert_id === null) {
return null;
}
$account_key = $this->key_store->accountKey();
if ($account_key === null) {
return null;
}
$client = $this->makeClient($account_key);
$client->fetchDirectory();
return $client->renewalInfo($ari_cert_id);
}
}