LetsEncrypt PHP API with BIND DNS server for ACME DNS-01 challenge setup guide

Are you looking to setup your own DNS server for LetsEncrypt's ACME DNS-01 verification challenges with PHP API then this guide is for you. LetsEncrypt wild card certificates can also be requested using the same DNS records. I use Debian Linux so this guide is based on Debian 12 at the time of this writing. This guide assumes that you already have PHP installed on your system.

Domain registrar DNS records setup

First add a new DNS record for your dns server, for example dns.example.com AAAA 2001:0db8:a55b:42df:5d01:2359:a67e:737d or / and dns.example.com A 203.0.113.9 A/AAAA record with your server IP where you will serve your BIND9 DNS server.

Now for each hostname create a NS record in your domain registrar, for example.
NS _acme-challenge.example.com dns.example.com
NS _acme-challenge.www.example.com dns.example.com
NS _acme-challenge.homeserver.example.com dns.example.com
NS _acme-challenge.fileserver.example.com dns.example.com
NS _acme-challenge.gameserver.example.com dns.example.com
NS _acme-challenge.plexserver.example.com dns.example.com

Server Setup

Install BIND9 DNS server apt install bind9 dnsutils

Now we start configuring the BIND9 server. This example includes the main domain which covers all the subdomains using the DNS server for generating LetsEncrypt certificates

/etc/bind/named.conf.local
//
// Do any local configuration here
//

// Consider adding the 1918 zones here, if they are not used in your
// organization
//include "/etc/bind/zones.rfc1918";

zone "example.com" IN {
    type master;
    file "/var/lib/bind/example.com.zone";
    allow-update { ::1; 127.0.0.1; };
    notify no;
};

Now configure named.conf.options file, replace the example IP with your own, for IPv4 use listen-on { 127.0.0.1; 203.0.113.9; };, I only use IPv6 so mine is set to listen-on-v6 { ::1; 2001:0db8:a55b:42df:5d01:2359:a67e:737d; };

/etc/bind/named.conf.options
options {
	directory "/var/cache/bind";

	// If there is a firewall between you and nameservers you want
	// to talk to, you may need to fix the firewall to allow multiple
	// ports to talk.  See http://www.kb.cert.org/vuls/id/800113

	// If your ISP provided one or more IP addresses for stable
	// nameservers, you probably want to use them as forwarders.
	// Uncomment the following block, and insert the addresses replacing
	// the all-0's placeholder.

	// forwarders {
	// 	0.0.0.0;
	// };

	//========================================================================
	// If BIND logs error messages about the root key being expired,
	// you will need to update your keys.  See https://www.isc.org/bind-keys
	//========================================================================
	dnssec-validation auto;
	allow-transfer {none;};
	allow-notify {none;};
	allow-recursion {none;};
	allow-query-cache {none;};
	recursion no;
	minimal-any yes;
	minimal-responses yes;

	listen-on { 127.0.0.1; 203.0.113.9; };
	listen-on-v6 { ::1; 2001:0db8:a55b:42df:5d01:2359:a67e:737d; };
};

Now create a new zone file listed above (/var/lib/bind/example.com.zone), replace the values accordingly.

Last line in this file must be a blank line.

/var/lib/bind/example.com.zone
$ORIGIN .
$TTL 0	; 0 minutes
example.com		IN SOA	dns.example.com. email.example.com. (
				100        ; serial
				3600       ; refresh (1 hour)
				3600       ; retry (1 hour)
				3600       ; expire (1 hour)
				0          ; minimum (0 seconds)
				)
$TTL 900	; 15 minutes
		IN	NS	dns.example.com.
; IP records for name servers
dns.example.com.             IN      AAAA       2001:0db8:a55b:42df:5d01:2359:a67e:737d
dns.example.com.             IN      A          203.0.113.9

Now check the zone file by running named-checkzone example.com. /var/lib/bind/example.com.zone

Now restart BIND server with your new settings systemctl restart bind9

PHP Setup

Now create a new file acme.php with the content below where your BIND9 server and PHP is installed. Passwords for example.com and www.example.com needs to be the same.

https://example.com/acme.php
<?php
header('Content-Type: text/plain');

// DNS Time to live in seconds
$dnsttl = '0';

$hostname = $_GET['hostname'] ?? null;
$txtrecord = $_GET['txt'] ?? null;
$dnsKey = $_GET['password'] ?? null;
$action = $_GET['action'] ?? null;

if (isset($dnsKey) && preg_match('/[^a-zA-Z0-9]/', $dnsKey))
{echo 'badauth';exit;}

if (isset($hostname) && preg_match('/[^a-z0-9\.\-\_]/', $hostname))
{echo 'notfqdn';exit;}

if (isset($txtrecord) && preg_match('/[^a-zA-Z0-9\.\-\_]/', $txtrecord))
{echo 'badsys';exit;}

// Credintials format: hostname (_acme-challenge.example.com), password (y6piHUklqGhZn6BhULmYraNhEfZKlSep), name of DNS zone to update (example.com)
$login = false;
$user_info=[
'_acme-challenge.example.com'=>['y6piHUklqGhZn6BhULmYraNhEfZKlSep','example.com'],
'_acme-challenge.www.example.com'=>['y6piHUklqGhZn6BhULmYraNhEfZKlSep','example.com'],
'_acme-challenge.homeserver.example.com'=>['lSOd73rMh1P9j8kpow24bbBDDWzkekwh','example.com'],
'_acme-challenge.fileserver.example.com'=>['KM2gy8nS5W1P0OrbqbtmqvBlvtZfKn0F','example.com'],
'_acme-challenge.gameserver.example.com'=>['DVhaFA3QoIDeg02edTEHAwtyzEPByXM1','example.com'],
'_acme-challenge.plexserver.example.com'=>['6D1RKde1zlh0vYL47Df1x3UjuoCfyqMJ','example.com']
];
foreach ($user_info as $key => $value) {
$login = ($key == $hostname && $value[0] == $dnsKey) ? true : false;
if($login) break;
}
if(!$login) {
echo 'badauth';
exit;
}

if ($hostname == null) {
header('HTTP/1.1 400 Bad Request');
echo 'notfqdn';
exit;
}

if (empty($txtrecord))
{
	echo 'emptytxtrecord';
	exit;
}

if ($action == 'delete') {
$descriptorspec = array(
	0 => array('pipe', 'r'),
	1 => array('pipe', 'w')
);
$process = proc_open('nsupdate', $descriptorspec, $pipes, NULL, NULL);
fwrite($pipes[0], "server ::1\n");
fwrite($pipes[0], "zone $value[1]\n");
fwrite($pipes[0], "update delete $hostname. TXT $txtrecord\n");
fwrite($pipes[0], "send\n");
fclose($pipes[0]);
echo stream_get_contents($pipes[1]);
proc_close($process);
echo 'good';
exit;
}
else
{
	txt_update();
}

function txt_update()
{
global $hostname, $txtrecord, $dnsttl, $value;
$descriptorspec = array(
	0 => array('pipe', 'r'),
	1 => array('pipe', 'w')
);
$process = proc_open('nsupdate', $descriptorspec, $pipes, NULL, NULL);
fwrite($pipes[0], "server ::1\n");
fwrite($pipes[0], "zone $value[1]\n");
fwrite($pipes[0], "update delete $hostname. TXT $txtrecord\n");
fwrite($pipes[0], "update add $hostname. $dnsttl TXT $txtrecord\n\n");
fclose($pipes[0]);
echo stream_get_contents($pipes[1]);
proc_close($process);
}

echo 'good';
?>

Client Server Setup

Now install acme.sh curl https://get.acme.sh | sh -s email=my@example.com

We now need to create a new acme DNS plugin to interact with our PHP API. After installing acme.sh create a new file (dns_phpbind.sh) in the acme plugin directory named /root/.acme.sh/dnsapi/dns_phpbind.sh

/root/.acme.sh/dnsapi/dns_phpbind.sh
#!/usr/bin/bash

#Here is a sample custom api script.
#This file name is "dns_phpbind.sh"
#So, here must be a method   dns_phpbind_add()
#Which will be called by acme.sh to add the txt record to your api system.
#returns 0 means success, otherwise error.
#
#Author: Neilpang
#Report Bugs here: https://github.com/acmesh-official/acme.sh
#
########  Public functions #####################

# Please Read this guide first: https://github.com/acmesh-official/acme.sh/wiki/DNS-API-Dev-Guide

# Usage:
# export PHPBIND_PASSWORD="y6piHUklqGhZn6BhULmYraNhEfZKlSep"
# export PHPBIND_DNS_SERVER="https://example.com/acme.php"
# /usr/local/ssl/acme.sh/acme.sh --dns dns_phpbind --issue -d domain.tld

#Usage: dns_phpbind_add   _acme-challenge.www.example.com   "y6piHUklqGhZn6BhULmYraNhEfZKlSep"

dns_phpbind_add() {
  fulldomain=$1
  txtvalue=$2
  _info "Using phpbind"
  _debug fulldomain "$fulldomain"
  _debug txtvalue "$txtvalue"

  PHPBIND_PASSWORD="${PHPBIND_PASSWORD:-$(_readaccountconf_mutable PHPBIND_PASSWORD)}"
  PHPBIND_DNS_SERVER="${PHPBIND_DNS_SERVER:-$(_readaccountconf_mutable PHPBIND_DNS_SERVER)}"

  if [ -z "$PHPBIND_PASSWORD" ]; then
    PHPBIND_PASSWORD=""
    _err "You didn't specify the password yet."
    _err "Please specify the password and try again."
    return 1
  fi

  if [ -z "$PHPBIND_DNS_SERVER" ]; then
    PHPBIND_DNS_SERVER=""
    _err "You didn't specify the DNS server yet."
    _err "Please specify the DNS server and try again."
    return 1
  fi

  #save the credentials to the account conf file.
  _saveaccountconf_mutable PHPBIND_PASSWORD "$PHPBIND_PASSWORD"
  _saveaccountconf_mutable PHPBIND_DNS_SERVER "$PHPBIND_DNS_SERVER"

  uri="$PHPBIND_DNS_SERVER"
  data="?password=${PHPBIND_PASSWORD}&hostname=${fulldomain}&txt=${txtvalue}"
  result="$(_get "${uri}${data}")"
  _debug "Result: $result"

  if ! _startswith "$result" 'good'; then
    _err "Can't add $fulldomain"
    return 1
  fi

}

#Usage: fulldomain txtvalue
#Remove the txt record after validation.
dns_phpbind_rm() {
  fulldomain=$1
  txtvalue=$2
  _info "Using phpbind"
  _debug fulldomain "$fulldomain"
  _debug txtvalue "$txtvalue"

  PHPBIND_PASSWORD="${PHPBIND_PASSWORD:-$(_readaccountconf_mutable PHPBIND_PASSWORD)}"
  PHPBIND_DNS_SERVER="${PHPBIND_DNS_SERVER:-$(_readaccountconf_mutable PHPBIND_DNS_SERVER)}"

  uri="$PHPBIND_DNS_SERVER"
  data="?password=${PHPBIND_PASSWORD}&hostname=${fulldomain}&txt=${txtvalue}&action=delete"
  result="$(_get "${uri}${data}")"
  _debug "Result: $result"

  if ! _startswith "$result" 'good'; then
    _info "Can't remove $fulldomain"
  fi

}

####################  Private functions below ##################################

If you are setting up a freshly installed server which never had a DNS server before and needs a LetsEncrypt certificate for itself then you might have to change https to http and once everything starts working you can switch back to https for secure PHP API. You can also manually add a TXT record by calling the API in the web browser address bar (See below).

Run these commands in client terminal
export PHPBIND_PASSWORD="y6piHUklqGhZn6BhULmYraNhEfZKlSep"
export PHPBIND_DNS_SERVER="https://example.com/acme.php"

Acme.sh command to use.

acme.sh
acme.sh --issue --dns dns_phpbind -d 'example.com' -d 'www.example.com' --preferred-chain "ISRG Root X2" --keylength ec-256 --server letsencrypt

Manually adding and removing the TXT records

Adding TXT record
https://example.com/acme.php?password=y6piHUklqGhZn6BhULmYraNhEfZKlSep&hostname=_acme-challenge.example.com&txt=acmetxtrecordtoverify
Removing TXT record
https://example.com/acme.php?password=y6piHUklqGhZn6BhULmYraNhEfZKlSep&hostname=_acme-challenge.example.com&txt=acmetxtrecordtoverify&action=delete

Let me know if you have any comments or if there is any error in this guide.