<?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
*
* A single command-line tool for the ACME certificate subsystem. It
* dispatches on a verb so the executables directory holds one ACME
* entry point rather than one file per check. The verbs are:
*
* smoke drive one issuance through AcmeManager against a real
* certificate authority, end to end, before relying on
* automatic issuance; defaults to the Let's Encrypt
* staging directory so a first run cannot exhaust the
* production limits or disturb the live certificate
* renew-now force one issuance into the live certificate files,
* the same path the renewal job takes, against the
* production directory
* ari-check confirm CsrBuilder::ariCertId produces a well-formed
* ACME Renewal Information (RFC 9773) identifier for a
* certificate and that it round-trips to the same
* authority key identifier and serial the certificate
* carries; the renewal job depends on this extraction,
* whose result has varied between OpenSSL versions
* inspect print a certificate's subject, subject alternative
* names, issuer, and expiry
*
* Usage (run from the Yioop source directory):
*
* php src/executables/AcmeTool.php smoke [options] domain [domain...]
* php src/executables/AcmeTool.php renew-now [--email=ADDR]
* [domain...]
* php src/executables/AcmeTool.php ari-check /path/to/cert.pem
* php src/executables/AcmeTool.php inspect /path/to/cert.pem
*
* smoke options:
* --production use the production directory instead of staging
* (do this only once a staging run has succeeded;
* production limits are strict)
* --email=ADDR contact email for the account registration
* --install on success, also install into the live
* certificate files (off by default, and refused on
* a staging run so an untrusted certificate can
* never replace the live one)
*
* With no domains given, smoke and renew-now use the configured
* SECURE_DOMAINS.
*
* @author Chris Pollett
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @package seek_quarry\executables
*/
namespace seekquarry\yioop\executables;
use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\AcmeManager;
use seekquarry\yioop\library\AcmeKeyStore;
use seekquarry\yioop\library\AcmeChallengeStore;
use seekquarry\yioop\library\CertInstaller;
use seekquarry\yioop\library\CsrBuilder;
if (php_sapi_name() != 'cli') {
echo "This script must be run from the command line.\n";
exit(1);
}
require_once __DIR__ . '/../configs/Config.php';
/* AcmeManager logs each step through crawlLog, a function defined in
Utility.php. The class autoloader does not load functions, and
Config.php alone does not pull Utility in, so require it here so
the step logging works when this tool is invoked on its own. */
require_once __DIR__ . '/../library/Utility.php';
/** Staging directory: generous limits, untrusted certificates. */
const STAGING_DIRECTORY =
"https://acme-staging-v02.api.letsencrypt.org/directory";
/** Production directory: trusted certificates, strict limits. */
const PRODUCTION_DIRECTORY =
"https://acme-v02.api.letsencrypt.org/directory";
/**
* Dispatches the verb on the command line to its handler.
*
* @return int process exit status, 0 on success
*/
function run()
{
$argv = $_SERVER["argv"];
array_shift($argv);
$verb = array_shift($argv);
switch ($verb) {
case "smoke":
return runSmoke($argv);
case "renew-now":
return runRenewNow($argv);
case "ari-check":
return runAriCheck($argv);
case "inspect":
return runInspect($argv);
default:
return usage();
}
}
/**
* Prints the usage summary.
*
* @return int always 1, since usage is shown on a missing or unknown
* verb
*/
function usage()
{
echo "Usage: php AcmeTool.php <verb> [options]\n";
echo " smoke [--production] [--email=ADDR] [--install] " .
"domain [domain...]\n";
echo " renew-now [--email=ADDR] [domain...]\n";
echo " ari-check /path/to/cert.pem\n";
echo " inspect /path/to/cert.pem\n";
return 1;
}
/**
* Resolves the certificate domains: the ones given on the command
* line, or the configured SECURE_DOMAINS when none are given.
*
* @param array $domains domains parsed from the command line
* @return array the domains to use, possibly empty
*/
function resolveDomains($domains)
{
if (!empty($domains)) {
return $domains;
}
$configured = trim((string) C\p('SECURE_DOMAINS'));
if ($configured === "") {
return [];
}
return array_values(array_filter(array_map('trim',
explode(',', $configured))));
}
/**
* Drives one issuance through AcmeManager. Defaults to staging and
* to issue-only; keeps the account key and issued certificate in a
* scratch directory unless a production install is requested, so the
* live account key and certificate are left untouched.
*
* @param array $argv the arguments after the verb
* @return int process exit status, 0 on a successful issue
*/
function runSmoke($argv)
{
$use_production = false;
$do_install = false;
$email = "";
$domains = [];
foreach ($argv as $arg) {
if ($arg === "--production") {
$use_production = true;
} else if ($arg === "--install") {
$do_install = true;
} else if (strncmp($arg, "--email=", 8) === 0) {
$email = substr($arg, 8);
} else if (strncmp($arg, "--", 2) === 0) {
echo "Unknown option: $arg\n";
return 1;
} else {
$domains[] = $arg;
}
}
$domains = resolveDomains($domains);
if (empty($domains)) {
echo "No domains given and SECURE_DOMAINS is empty. Pass " .
"one or more domains on the command line.\n";
return 1;
}
$directory_url = $use_production ? PRODUCTION_DIRECTORY :
STAGING_DIRECTORY;
$where = $use_production ? "PRODUCTION" : "staging";
if ($email === "") {
$email = "admin@" . $domains[0];
}
/* A staging run must never install over the live certificate,
since the staging certificate is untrusted; refuse the
combination outright rather than silently ignoring it. */
if ($do_install && !$use_production) {
echo "Refusing --install on a staging run: a staging " .
"certificate is untrusted and must not replace the " .
"live certificate. Re-run with --production to install " .
"a real certificate.\n";
return 1;
}
echo "ACME smoke test\n";
echo " directory: $where ($directory_url)\n";
echo " domains: " . implode(", ", $domains) . "\n";
echo " contact: $email\n";
echo " install: " . ($do_install ? "yes (live files)" :
"no (issue only)") . "\n\n";
/* The running secure server serves the HTTP-01 challenge from
the live challenge directory on port 80, so the challenge
store must point there for validation to find the token. */
$challenge_store = new AcmeChallengeStore(C\ACME_CHALLENGE_DIR);
if ($use_production && $do_install) {
/* A production install writes the live files. */
$key_store = new AcmeKeyStore();
$cert_installer = new CertInstaller();
} else {
/* Otherwise keep the account key and issued certificate in a
scratch directory so the live account key and certificate
are left untouched. */
$scratch = C\WORK_DIRECTORY . "/acme/staging";
if (!is_dir($scratch)) {
@mkdir($scratch, 0700, true);
}
$key_store = new AcmeKeyStore($scratch);
$cert_installer = new CertInstaller(
$scratch . "/cert.fullchain.pem",
$scratch . "/cert.key");
echo " scratch: $scratch\n\n";
}
$manager = new AcmeManager($directory_url, $email, $key_store,
$challenge_store, $cert_installer);
$start = time();
$ok = $manager->obtainCertificate($domains);
$elapsed = time() - $start;
echo "\n";
if ($ok) {
echo "SUCCESS: issued and wrote a $where certificate in " .
"{$elapsed}s.\n";
if (!$use_production) {
echo "The certificate is untrusted (staging). Inspect " .
"it under " . C\WORK_DIRECTORY . "/acme/staging.\n";
}
return 0;
}
echo "FAILED after {$elapsed}s. See the step log above for the " .
"point of failure.\n";
return 1;
}
/**
* Forces one issuance into the live certificate files against the
* production directory, the same path the renewal job takes. This is
* the manual form of a renewal: it uses the live account key, the
* live challenge directory, and the live certificate files.
*
* @param array $argv the arguments after the verb
* @return int process exit status, 0 on a successful issue
*/
function runRenewNow($argv)
{
$email = "";
$domains = [];
foreach ($argv as $arg) {
if (strncmp($arg, "--email=", 8) === 0) {
$email = substr($arg, 8);
} else if (strncmp($arg, "--", 2) === 0) {
echo "Unknown option: $arg\n";
return 1;
} else {
$domains[] = $arg;
}
}
$domains = resolveDomains($domains);
if (empty($domains)) {
echo "No domains given and SECURE_DOMAINS is empty. Pass " .
"one or more domains on the command line.\n";
return 1;
}
if ($email === "") {
$email = "admin@" . $domains[0];
}
echo "ACME renew-now\n";
echo " directory: PRODUCTION (" . PRODUCTION_DIRECTORY . ")\n";
echo " domains: " . implode(", ", $domains) . "\n";
echo " contact: $email\n";
echo " install: yes (live files)\n\n";
$manager = new AcmeManager(PRODUCTION_DIRECTORY, $email);
$start = time();
$ok = $manager->obtainCertificate($domains);
$elapsed = time() - $start;
echo "\n";
if ($ok) {
echo "SUCCESS: renewed and wrote the live certificate in " .
"{$elapsed}s.\n";
return 0;
}
echo "FAILED after {$elapsed}s. See the step log above for the " .
"point of failure.\n";
return 1;
}
/**
* Confirms that CsrBuilder::ariCertId produces a well-formed ACME
* Renewal Information (RFC 9773) identifier for a certificate and
* that it round-trips: the identifier is base64url(authority key
* identifier) . base64url(serial), so decoding both halves must give
* back the authority key identifier and serial the certificate
* carries. This is the one place the extraction has historically
* varied between OpenSSL versions, so it is worth confirming on the
* deployment's OpenSSL before renewal relies on it.
*
* @param array $argv the arguments after the verb
* @return int 0 when the identifier is well formed and matches
*/
function runAriCheck($argv)
{
if (empty($argv[0])) {
echo "Usage: php AcmeTool.php ari-check /path/to/cert.pem\n";
return 1;
}
$path = $argv[0];
if (!is_file($path)) {
echo "No such file: $path\n";
return 1;
}
$cert_pem = file_get_contents($path);
$parsed = openssl_x509_parse($cert_pem);
if ($parsed === false) {
echo "Could not parse a certificate from $path.\n";
return 1;
}
$subject = $parsed["subject"]["CN"] ?? "?";
$aki_raw = $parsed["extensions"]["authorityKeyIdentifier"] ?? "";
$serial_hex = strtoupper($parsed["serialNumberHex"] ?? "");
echo "Certificate: $path\n";
echo " subject CN: $subject\n";
echo " AKI extension: " . trim((string)$aki_raw) . "\n";
echo " serial (hex): $serial_hex\n\n";
$id = CsrBuilder::ariCertId($cert_pem);
if ($id === null) {
echo "FAIL: ariCertId returned null (could not extract the " .
"authority key identifier or serial).\n";
return 1;
}
echo "ariCertId: $id\n";
$parts = explode(".", $id);
if (count($parts) !== 2 || $parts[0] === "" || $parts[1] === "") {
echo "FAIL: the id is not two non-empty dot-separated " .
"base64url parts.\n";
return 1;
}
$aki_hex = strtoupper(bin2hex(base64urlDecode($parts[0])));
$serial_round = strtoupper(bin2hex(base64urlDecode($parts[1])));
$first_line = substr((string)$aki_raw, 0,
strpos((string)$aki_raw . "\n", "\n"));
$expect_aki = strtoupper(preg_replace('/[^0-9A-Fa-f]/', "",
$first_line));
echo " decoded AKI (hex): $aki_hex\n";
echo " expected AKI (hex): $expect_aki\n";
echo " decoded serial (hex): $serial_round\n";
echo " expected serial: $serial_hex\n\n";
$aki_ok = ($aki_hex !== "" && $aki_hex === $expect_aki);
$serial_ok = ($serial_round === $serial_hex);
echo " AKI matches: " . ($aki_ok ? "yes" : "NO") . "\n";
echo " serial matches: " . ($serial_ok ? "yes" : "NO") . "\n\n";
if ($aki_ok && $serial_ok) {
echo "PASS: ariCertId is well formed and matches the " .
"certificate on this OpenSSL.\n";
return 0;
}
echo "FAIL: the ARI id does not match the certificate; the " .
"authority key identifier extraction needs review on this " .
"OpenSSL version.\n";
return 1;
}
/**
* Prints a certificate's subject, subject alternative names,
* issuer, and expiry. A quick read of what a certificate file
* actually contains, for confirming an install.
*
* @param array $argv the arguments after the verb
* @return int 0 when the certificate parsed
*/
function runInspect($argv)
{
if (empty($argv[0])) {
echo "Usage: php AcmeTool.php inspect /path/to/cert.pem\n";
return 1;
}
$path = $argv[0];
if (!is_file($path)) {
echo "No such file: $path\n";
return 1;
}
$cert_pem = file_get_contents($path);
$parsed = openssl_x509_parse($cert_pem);
if ($parsed === false) {
echo "Could not parse a certificate from $path.\n";
return 1;
}
$subject = $parsed["subject"]["CN"] ?? "?";
$issuer_parts = [];
foreach ($parsed["issuer"] ?? [] as $key => $value) {
$issuer_parts[] = "$key=$value";
}
$issuer = implode(", ", $issuer_parts);
$sans = $parsed["extensions"]["subjectAltName"] ?? "(none)";
$not_after = isset($parsed["validTo_time_t"])
? gmdate("Y-m-d H:i:s", $parsed["validTo_time_t"]) . " UTC"
: "(unknown)";
$not_before = isset($parsed["validFrom_time_t"])
? gmdate("Y-m-d H:i:s", $parsed["validFrom_time_t"]) . " UTC"
: "(unknown)";
echo "Certificate: $path\n";
echo " subject CN: $subject\n";
echo " SANs: $sans\n";
echo " issuer: $issuer\n";
echo " not before: $not_before\n";
echo " not after: $not_after\n";
if (isset($parsed["validTo_time_t"])) {
$days = (int) floor(($parsed["validTo_time_t"] - time())
/ 86400);
echo " days left: $days\n";
}
return 0;
}
/**
* Decodes a base64url string back to raw bytes, restoring the
* padding base64url omits.
*
* @param string $text base64url text
* @return string raw decoded bytes
*/
function base64urlDecode($text)
{
$standard = strtr($text, "-_", "+/");
$remainder = strlen($standard) % 4;
if ($remainder > 0) {
$standard .= str_repeat("=", 4 - $remainder);
}
return base64_decode($standard);
}
exit(run());