/ src / library / AcmeManager.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;

/**
 * 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);
    }
}
X