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
//
// 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; };
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.
$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.
<?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
#!/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).
export PHPBIND_PASSWORD="y6piHUklqGhZn6BhULmYraNhEfZKlSep"
export PHPBIND_DNS_SERVER="https://example.com/acme.php"
Acme.sh command to use.
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
https://example.com/acme.php?password=y6piHUklqGhZn6BhULmYraNhEfZKlSep&hostname=_acme-challenge.example.com&txt=acmetxtrecordtoverify
https://example.com/acme.php?password=y6piHUklqGhZn6BhULmYraNhEfZKlSep&hostname=_acme-challenge.example.com&txt=acmetxtrecordtoverify&action=delete
Related: LetsEncrypt BIND DNS and ACME DNS-01 server setup guide
Let me know if you have any comments or if there is any error in this guide.