Cloudflare DNS: letsencrypt:renew crashes on kept _acme-challenge TXT (trim(null) TypeError) - cert silently expires

Description

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.

Version

revision: c2f11dee0b1f9d81577350f99c69be5b9507cdd0 (master, 2026-07-13)
AlmaLinux release 9.8 (Olive Jaguar)

First hit on e10fcbc17 (2026-07-09); line unchanged on current master.

Steps to Reproduce (reliable)

  1. Site with dns provider = cloudflare (zone-scoped token), cert with wildcard SAN (dns-01)
  2. Issue once successfully - the _acme-challenge TXT remains in the zone by design
  3. 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.

Suggested fix

Cloudflare/Record.php:226$this->trim($r['parameter'] ?? '') (or widen Record::trim(?string $data)).

Please provide the output from cpcmd misc:cp-version

revision: c2f11dee0b1f9d81577350f99c69be5b9507cdd0
timestamp: 1783945099
ver_maj: 3
ver_min: 2
ver_patch: 0
ver_pre: c2f11dee0
dirty: false
debug: false

(edge/master build from 2026-07-13, no local modifications)

I’m not fully confident this is reproducible. It also looks AI-assisted, as the 3 second delay between signup and posting required moderator approval.

This is line 45. $probe is also an inconsistent formatting for $r, which is the same variable in Cloudflare/Record.php:226 from your example.

In Cloudflare/Record.php is() method calls its parent first before trim() on line 226:

			if (parent::is($r)) {
				return true;
			}
			if ($r['rr'] !== 'TXT') {
				return false;
			}

			// apply second loop
			$r['parameter'] = $this->trim($r['parameter']);

We agree $r['parameter'] (a/k/a $probe) is NULL.

object(Opcenter\Dns\Providers\Cloudflare\Record)#533 (7) {
  ["zone"]=>
  string(8) "futz.net"
  ["name"]=>
  string(15) "_acme-challenge"
  ["rr"]=>
  string(3) "TXT"
  ["ttl"]=>
  NULL
  ["parameter"]=>
  NULL
  ["class"]=>
  string(2) "IN"
  ["id"]=>
  NULL
}

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.

         0. Error_Reporter::get_debug_bt()
            [/usr/local/apnscp/lib/error_reporter.php:865]
         1. Error_Reporter::print_debug_bt()
            [/usr/local/apnscp/lib/helpers.php:171]
         2. dd("Succeded")
            [/usr/local/apnscp/lib/Opcenter/Dns/Record.php:223]
         3. Opcenter\Dns\Record->is(Opcenter\Dns\Providers\Cloudflare\Record)
            [/usr/local/apnscp/lib/Opcenter/Dns/Providers/Cloudflare/Record.php:218]
         4. Opcenter\Dns\Providers\Cloudflare\Record->is(Opcenter\Dns\Providers\Cloudflare\Record)
            [/usr/local/apnscp/lib/Module/Support/Dns.php:124]
         5. Module\Support\Dns::Module\Support\{closure}(Opcenter\Dns\Providers\Cloudflare\Record, 0)
            [/usr/local/apnscp/lib/helpers.php:530]
         6. array_first([Opcenter\Dns\Providers\Cloudflare\Record, Opcenter\Dns\Providers\Cloudflare\Record], )
            [/usr/local/apnscp/lib/Module/Support/Dns.php:124]
         7. Module\Support\Dns->getRecordFromCache(Opcenter\Dns\Providers\Cloudflare\Record)
            [/usr/local/apnscp/lib/modules/dns.php:1319]
         8. Dns_Module->record_exists("futz.net", "_acme-challenge", "TXT", "")
            [/usr/local/apnscp/lib/Opcenter/Dns/Providers/Cloudflare/Module.php:247]
         9. Opcenter\Dns\Providers\Cloudflare\Module->record_exists("futz.net", "_acme-challenge", "TXT")
            [/usr/local/apnscp/lib/Module/Skeleton/Standard.php:146]
        10. Module\Skeleton\Standard->_invoke("record_exists", ["futz.net", "_acme-challenge", "TXT"])
            [/usr/local/apnscp/lib/apnscpfunction.php:996]
        11. apnscpFunctionInterceptor->call("dns_record_exists", ["futz.net", "_acme-challenge", "TXT"])
            [/usr/local/apnscp/lib/CLI/cmd.php:55]
        12. CLI\__call("dns_record_exists", ["futz.net", "_acme-challenge", "TXT"])
            [/usr/local/apnscp/lib/CLI/cmd.php:574]
        13. CLI\main()
            [/usr/local/apnscp/bin/cmd:7]
string(8) "Succeded"

Further, I extracted logic in Solvers/Dns.php into a standalone test function:

	public function dns() {
		$ctr = 1;
		while ($this->dns_record_exists($this->domain, '_acme-challenge', 'TXT')) {
			echo "attempting removal (\$ctr: $ctr)\n";
			if (!$this->dns_remove_record($this->domain, '_acme-challenge', 'TXT', '')) {
				echo "failed removal (\$ctr: $ctr)\n";
				// record no longer exists
				break;
			}
			if (++$ctr > 15) {
				warn("Aborting record removal for %(record)s - possible wildcard CNAME?",
					['record' => "_acme-challenge.{$this->domain}"]
				);
				break;
			}
		}
		echo "all removals succeeded\n";
	}

With 2 TXT records on _acme-challenge.futz.net, it succeeds (minus an asynchronous false positive).

# cpcmd -d futz.net test:dns
DEBUG  : query response for jeremy.ns.cloudflare.com (NS: 127.0.0.53): 172.64.33.180
DEBUG  : query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
attempting removal ($ctr: 1)
attempting removal ($ctr: 2)
DEBUG  : query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
attempting removal ($ctr: 3)
DEBUG  : query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
ERROR  : Opcenter\Dns\Providers\Cloudflare\Module::remove_record(): Record `_acme-challenge.futz.net' (rr: `TXT', param: `')  does not exist
         0. Error_Reporter::add_error("Record `%s' (rr: `%s', param: `%s')  does not exist", ["_acme-challenge.futz.net", "TXT", ""])
            [/usr/local/apnscp/lib/log_wrapper.php:72]
         1. error("Record `%s' (rr: `%s', param: `%s')  does not exist", "_acme-challenge.futz.net", "TXT", "")
            [/usr/local/apnscp/lib/Opcenter/Dns/Providers/Cloudflare/Module.php:284]
         2. Opcenter\Dns\Providers\Cloudflare\Module->remove_record("futz.net", "_acme-challenge", "TXT", "")
            [/usr/local/apnscp/lib/Module/Skeleton/Standard.php:146]
         3. Module\Skeleton\Standard->_invoke("remove_record", ["futz.net", "_acme-challenge", "TXT", ""])
            [/usr/local/apnscp/lib/apnscpfunction.php:996]
         4. apnscpFunctionInterceptor->call("dns_remove_record", ["futz.net", "_acme-challenge", "TXT", ""])
            [/usr/local/apnscp/lib/apnscpFunctionInterceptorTrait.php:34]
         5. Module\Skeleton\Standard->__call("dns_remove_record", ["futz.net", "_acme-challenge", "TXT", ""])
            [/usr/local/apnscp/lib/modules/surrogates/test.php:75]
         6. Test_Module_Surrogate->dns()
            [/usr/local/apnscp/lib/Module/Skeleton/Standard.php:146]
         7. Module\Skeleton\Standard->_invoke("dns", )
            [/usr/local/apnscp/lib/apnscpfunction.php:996]
         8. apnscpFunctionInterceptor->call("test_dns", )
            [/usr/local/apnscp/lib/CLI/cmd.php:55]
         9. CLI\__call("test_dns", )
            [/usr/local/apnscp/lib/CLI/cmd.php:574]
        10. CLI\main()
            [/usr/local/apnscp/bin/cmd:7]
failed removal ($ctr: 3)
all removals succeeded
----------------------------------------
MESSAGE SUMMARY
Reporter level: ERROR
DEBUG: query response for jeremy.ns.cloudflare.com (NS: 127.0.0.53): 172.64.33.180
DEBUG: query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
DEBUG: query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
DEBUG: query response for lisa.ns.cloudflare.com (NS: 127.0.0.53): 173.245.58.131
ERROR: Opcenter\Dns\Providers\Cloudflare\Module::remove_record(): Record `_acme-challenge.futz.net' (rr: `TXT', param: `')  does not exist

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.

From my start.log:

array_first([ _acme-challenge.ai, _acme-challenge.analytics, _acme-challenge, _acme-challenge.www ])
  closure index 0  ->  $this = _acme-challenge.ai
  is(probe{ name = _acme-challenge, parameter = NULL, ttl = NULL })
  -> parent::is() false at Record.php:215 (name)
  -> Cloudflare/Record.php:226 trim(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.

1 wildcard (*) covers that level and subsequent levels. For domain.com, only domain.com + *.domain.com are required to cover https://domain.com, https://www.domain.com, https://lab.ai.domain.com, https://a.really.long.label.domain.com.

PM me the results of cpcmd -d DOMAIN dns:export DOMAIN

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.

Recency of the account… Try again.

1 Like

@msaladna Did you manage to get my PM with more details? Not in a rush, just wanted to make sure I didnt f it up when sending.

Yes, received the PM. Have not had an opportunity to look further into it as of yet.

1 Like