letsencrypt:renew crashes with an uncaught TypeError on the Cloudflare DNS provider whenever the zone still contains the _acme-challenge TXT record kept by a previous successful dns-01 issuance (Solvers/Dns.php - “remove if exists, keep record at end for user to reuse elsewhere”). Every wildcard issuance keeps that record, so every following renewal crashes and the cert silently runs to expiry. No alert; found via near-expired cert + storage/logs/start.log.
First hit on e10fcbc17 (2026-07-09); line unchanged on current master.
Steps to Reproduce (reliable)
Site with dns provider = cloudflare (zone-scoped token), cert with wildcard SAN (dns-01)
Issue once successfully - the _acme-challenge TXT remains in the zone by design
env DEBUG=1 cpcmd -d siteX letsencrypt:renew
Actual Behavior
TypeError: Opcenter\Dns\Record::trim(): Argument #1 ($data) must be of type string, null given,
called in lib/Opcenter/Dns/Providers/Cloudflare/Record.php on line 226
Solvers/Dns.php:44 dns_record_exists($domain, '_acme-challenge', 'TXT')
modules/dns.php:1320 record_exists() probe: ['parameter' => $parameter !== '' ? $parameter : null]
Module/Support/Dns.php:124 getRecordFromCache -> $x->is($probe)
Cloudflare/Record.php:226 $r['parameter'] = $this->trim($r['parameter']); // null
Dns/Record.php:287 protected function trim(string $data): string // fatal
Same class as the Hetzner report from Aug 2024 (t/616, “unhandled exception on a weak DNS match”, fixed in edge): Hetzner formatTxt() got the !empty + (string) guard; the Cloudflare is() override (the only provider with one) still passes null through. Linode/Record.php:26 and Powerdns/Record.php:84,107 look similar (unverified).
Workaround
Delete leftover _acme-challenge* TXT records in the CF zone, renew again. First retry may hit [malformed] No such challenge (reused pending order); second retry succeeds.
In Dns\Record.php’s is() implementation, line 199 returns true on line 222 if $r['parameter'] is an empty string, empty array, 0 or NULL (empty() function in PHP)
if (empty($r['parameter'])) {
// dd("Succeeded"); /* added for illustration */
return true;
}
Because $r['parameter'] is NULL, empty(NULL) is true and the parent method succeeds, trim() is not invoked at all. There is no logical way to reach that code given its parameters.
This is verified with a simple exception thrown before returning true.
Thanks for taking the time to test it and post the backtrace - that is what let me spot the bit I had left out.
yeah, i used AI to even post it as it is much quicker at formatting, while it did rename variables for whatever reason… I just wanted to provide further report that matches similar case from before. The rest is a non-critical or importabt, but still a bug i discovered and wanted to contribute, perhaps it is only applicable to me, but as I keep introducing apiscp with friends, i want to do as much as i can, even as small as this.
On reproducing it - the precondition I left out is a SAN on a subdomain. My cert covers ai., analytics. and www., so the zone keeps _acme-challenge.<sub> records alongside _acme-challenge.
getCacheKey() returns 'records.' . RR . '.' . name, a dot path - so _acme-challenge.www is stored nested underneath _acme-challenge. getMatchingRecordsFromCache() (Module/Support/Dns.php:138) then flattens those nested branches back into the candidate list. getRecordId() skips exactly those at line 105 (“nested records e.g. foo.bar.baz.com while examining bar.baz.com”); getRecordFromCache() has no such skip.
So is() runs against a foreign-name record and returns false at Dns/Record.php:215 on the name mismatch - it never reaches the empty($r['parameter']) short-circuit at 222. Cloudflare’s override then reaches line 226 with NULL.
It is also order-dependent: array_first stops at the first match, so when the exact-name record is flattened ahead of the subdomain ones the empty-parameter check wins and nothing crashes. That is probably why two TXT records on the same name pass.
Still present at 437c5e5cc.
Happy to run anything you want checked against my box - it reproduces there on every renewal, so I can get you whatever output helps.
I get “An error occurred: Sorry, you cannot send a personal message to that user.”, so i send to group “moderators” i saw that somewhere as the way to send you PM, but i am no longer sure it was the correct way.