Environment
ApisCP:
ApisCP v3.2.0
revision: 75f8143eb3311984028ce9f7406e42694f11e75c (2026/09/25)
PHP 8.3.24
Affects any account on a plan with apache enabled=0.
Why an email-only account has a certificate at all. An account with Apache
disabled still needs a TLS certificate. Mail is offered over IMAPS (993), POP3S
(995) and SMTPS (465), terminated by HAProxy, and mail clients reject an expired
or untrusted certificate exactly as a browser would. Disabling Apache removes the
web server, not the need for the certificate — so the certificate is legitimately
issued and installed against an account that has no web vhost.
That is precisely the combination that breaks renew() below.
The ssl service value is not the deciding factor, which is worth stating
because it is the first thing to reach for. ssl_install()
(lib/modules/ssl.php:360, ssl::install()) enables the service on the account
as part of installing a certificate:
// pre-flight checks done, let's install
if (!$overwrite || !$this->enabled()) {
$cmd = new Util_Account_Editor($this->getAuthContext()->getAccount(), $this->getAuthContext());
$cmd->setConfig(SiteConfiguration::getModuleRemap('openssl'), 'enabled', 1);
// ensure HTTP config is rebuild
$cmd->edit();
}
So an email account carries a live, in-use certificate regardless of what its
plan sets. Setting ssl enabled=1 in the plan — which we now do, and which is
correct — does not avoid the bug: the lookup that fails depends on the
account having an Apache vhost, and apache enabled=0 prevents that by design.
Verified on the affected account after enabling ssl in the plan:
$ cpcmd -d email-only.com.au -i json ssl_get_certificates
{ } # apache=0, ssl=1, certificate in active use
$ cpcmd -d web-site.com.au -i json ssl_get_certificates
- key: server.key # apache=1
crt: server.crt
chain: bundle.crt
host: 221.121.144.23
Summary
letsencrypt:renew cannot renew the certificate on an account whose plan
disables Apache. It reports:
WARNING: Letsencrypt_Module::renew(): Certificate is not Let's Encrypt
The certificate is Let’s Encrypt. The message describes a failed lookup, not
the certificate’s issuer, so it points away from the real cause. The account
never renews, and the failure is silent — it only becomes visible roughly 80
days later when mail clients start rejecting an expired certificate.
Steps to reproduce
-
Create an account that mirrors the email plans above — Apache disabled, with a
certificate for mail:AddDomain -c siteinfo,domain=example.com \ -c siteinfo,admin_user=example \ -c auth,passwd=1 \ -c apache,enabled=0 \ -c ssl,enabled=1 -
Issue a certificate, working around the apex/IP check if the domain’s A
record points elsewhere:cpcmd -d example.com letsencrypt_request '["*.example.com"]' falseThis succeeds:
INFO : successfully renewed certificate for 90 days Reporter level: SUCCESS -
Now attempt a renewal — the path housekeeping takes automatically:
cpcmd -d example.com letsencrypt_renew
Actual result
WARNING: Letsencrypt_Module::renew(): Certificate is not Let's Encrypt
----------------------------------------
MESSAGE SUMMARY
Reporter level: WARNING
WARNING: Letsencrypt_Module::renew(): Certificate is not Let's Encrypt
----------------------------------------
Nothing is renewed. No error is raised, and nothing is written to the log beyond
this warning.
Root cause
renew() resolves the hostnames to re-request via
getNonOrphanedDomainsFromCertificate(), which calls getSanFromCertificate()
with no argument (lib/modules/letsencrypt.php:534):
private function getNonOrphanedDomainsFromCertificate(): ?array
{
if (null === ($cns = $this->getSanFromCertificate())) {
// not Let's Encrypt
return null;
}
getSanFromCertificate() (lib/Module/Support/Letsencrypt.php:375, pulled into
the module via use Module\Support\Letsencrypt;) only consults the ACME storage
directory when it is given an explicit $cert argument. With no argument it
falls through to ssl_get_certificate():
protected function getSanFromCertificate(?string $cert = null): ?array
{
if (!$cert && $this->permission_level & PRIVILEGE_ADMIN) {
$cert = LEService::SYSCERT_NAME;
}
if (!$cert) {
if (!$cert = $this->ssl_get_certificate()) { // <-- reads the Apache vhost
return null;
}
} else if (is_dir($path = LEService::acmeSiteStorageDirectory($cert))) {
$components = LEService::getCertificateComponentData($cert);
$cert = $components['crt'] . "\n" . $components['chain'];
} else {
return null;
}
ssl_get_certificate() derives the certificate from the account’s Apache
vhost — ssl_get_certificates() parses /etc/httpd/conf/virtual/<site>, which
is surfaced into conf/virtual-httpd-built. An account with
apache enabled=0 has no vhost, so:
cpcmd -d example.com -i json ssl_get_certificates
returns { } even though the certificate is installed and in active use for
mail. getSanFromCertificate() therefore returns null, and renew() takes
the // not Let's Encrypt branch.
The class already calls the correct form elsewhere — filterMissingHostnames()
(lib/Module/Support/Letsencrypt.php:440) passes the site explicitly at line 442:
$new = array_flip((array)$this->getSanFromCertificate($this->site));
which resolves through the is_dir(acmeSiteStorageDirectory(...)) branch and
works regardless of whether Apache is enabled. getNonOrphanedDomainsFromCertificate()
omitting the argument looks like an oversight rather than a deliberate choice.
Expected result
renew() should resolve the certificate’s SANs for site-level contexts without
depending on a web vhost existing, and renew successfully — or, if a vhost
really is required, fail loudly with a message that names the actual problem
instead of reporting a certificate issuer mismatch.
Proposed fix
Pass the site through for site contexts, while preserving the admin behaviour of
resolving to the MAIN system certificate:
private function getNonOrphanedDomainsFromCertificate(): ?array
{
+ // Resolve from the ACME storage directory for site contexts. Without an
+ // explicit argument getSanFromCertificate() reads the certificate out of
+ // the site's Apache vhost, which does not exist for accounts whose plan
+ // disables apache (email-only, DNS-only) - renew() then reports
+ // "Certificate is not Let's Encrypt" and never renews.
+ $cert = $this->permission_level & PRIVILEGE_ADMIN ? null : $this->site;
- if (null === ($cns = $this->getSanFromCertificate())) {
+ if (null === ($cns = $this->getSanFromCertificate($cert))) {
// not Let's Encrypt
return null;
}
The ternary is deliberate: passing $this->site unconditionally would break
admin contexts, which have no site and must still resolve to MAIN.
Verification
Applied to lib/modules/letsencrypt.php on the 3.2.0 install described above,
php -l clean, systemctl restart apnscp.service, then:
# cpcmd -d email-only.com.au letsencrypt_renew
DEBUG : *.email-only.com.au already resolved by dns
INFO : reloading web server in 2 minutes, stay tuned!
INFO : reminder: only 5 duplicate certificates and 50 unique certificates may be issued per week per account
INFO : successfully renewed certificate for 90 days
----------------------------------------
MESSAGE SUMMARY
Reporter level: SUCCESS
Before the patch the same command produced only the Certificate is not Let's Encrypt warning and issued nothing. The account in question is apache enabled=0, ssl enabled=1, with no Apache vhost present.
Impact
Any account whose plan disables Apache cannot renew its certificate. That
includes email-only and DNS-only plans, which are a common way to sell mail
without web hosting — and which still require a valid certificate, because the
mail ports present one to every connecting client.
The failure is silent and delayed. Renewal returns without an error, so nothing
surfaces until roughly 80 days later when the certificate expires and mail
clients begin rejecting it. Because the warning reads as an issuer
classification rather than a lookup failure, the natural troubleshooting
direction is to inspect the certificate, which is valid and correct; the account
configuration is what actually matters.
Related observations
These are separate from the bug above but surfaced during the same
investigation, and both also affect email-only accounts.
1. verifyip passed as a command-line argument does not persist into
renewal. letsencrypt_request '["*.example.com"]' false sets verifyip for
that single call only. renew() re-reads the preference, falling back to
LETSENCRYPT_VERIFY_IP (true), so any domain whose apex resolves elsewhere
(a website on Squarespace, registrar parking, a separate mail host) is rejected
during renewal as not resolving to this server. The durable form is:
cpcmd -d example.com common_set_preference letsencrypt.verifyip false
Worth noting because verifyip=false on the issuance command looks like a
persisted setting and is not — a caller can reasonably believe the account is
configured for renewal when it is not.
2. A successful renewal does not redeploy to HAProxy. Mail TLS on
993/465/995 is terminated by HAProxy from /etc/haproxy/ssl.d/<site>.pem, which
is a different file from the web certificate. On a successful renewal the site’s
httpd path was updated but the proxy pem was not:
/home/virtual/site31/fst/etc/httpd/conf/ssl.crt/server.crt serial=06A6C23D...
/etc/haproxy/ssl.d/site31.pem serial=06642851... # stale
Mail kept serving the previous certificate after a renewal that reported
Reporter level: SUCCESS. In this case the site was email-only, so the web path
was not in use and the mismatch was only detectable by comparing serials. If the
email::merge_ssl() hook that is meant to sync this is expected to cover
renewals as well as initial issuance, it did not fire here.