14 minutes
Editing dynadot DNS-settings via API-calls in Bash, Part 1
The way I’ve done things here is most certainly not any form of ideal or proper. I’m an amateur coder, I smash rocks together until something sticks, and it starts working. I just hope that at some point in the future this post may be of help to someone trying something similar. After all, that’s why I started this blog. |
This is part 1 of (at least) 3 in my series of getting automatic SSL-cert renewals working on my Synology. This part covers how to read (and write) DNS-settings to dynadot via its API with bash. Part 2 will cover how to use this to automatically create an SSL-certificate with certbot. Part 3 will be about how to then automatically replace the SSL-certificate in a Synology NAS. (Let’s see if I can get there before the current cert expires…) |
Intro
Usually, SSL-certificates are renewed automatically nowadays. But unfortunately, when you cut corners to save money[1], not everything works as it should or even as you hope. I find myself in the awkward position where my Synology NAS is now available (to myself, friends and family) via a subdomain of this blog, at least over IPv6. Mostly just to see if I could figure out how to do that.
And I did! It even has a valid SSL-certificate. That will expire in about a month and my Synology isn’t able to automatically renew it. Yet.
This is most likely caused by something between Let’s Encrypt and my NAS that’s blocking port 80, which is needed for the handshake confirming it’s behind the (sub-)domain in question. I guess my ISP has a NAT between my router and the rest of the internet because they’ve run out of IPv4-addresses and combined my connection with that of my neighbours. And I’m pretty sure that’s also the reason why I can only get onto my NAS via IPv6.
Luckily, there is a manual way to create SSL-certs with Let’s Encrypt: certbot!
Certbot
Certbot is a terminal tool for getting SSL-certificates from Let’s Encrypt.
It’s installed the usual way:
sudo apt-get install certbot
It’s normal use case is for it to be tailored to your specific web-server environment and to then automatically getting and renewing your certs. I think. I haven’t really used it that way. Because my use case definitely isn’t normal.
Manual DNS Challenge Validation with Certbot
But no worries, certbot comes with a manual mode! Judging by the way it isn’t mentioned anywhere on its website and only discoverable if you carefully dig through its documentation, I’m sure the EFF doesn’t really want you to use it that way, but hey, I’m an IT-Neanderthal smashing rocks. It sticks, and then it works.
Despite all that, using it this way is rather easy. While it has many possible arguments/parameters, the following is enough to get a basic DNS-Challenge going:
sudo certbot certonly --preferred-challenges dns
certonly
tells certbot to just create the certificate files without (trying to) installing them on your system. This also means, that you can run this command on any machine you like and then transfer the cert files somewhere else.--preferred-challenges dns
ensures that the DNS-challenge method is actually used, otherwise it would try its default http-port-80 handshake. Which doesn’t work, because otherwise we wouldn’t be here…
How to go through the rest of the process from there should be pretty self-explanatory: You enter some needed info about yourself and the (sub-)domain you need the cert for and are then given a TXT-record that you have to enter into the DNS-configuration of said (sub-)domain[2]. Once you’ve done that and waited some time for it to propagate, you can tell certbot to continue and should receive your cert-files wherever certbot likes to put them (don’t worry, it’ll tell you).
(Semi-)Automatic Use of certbot
So far, so good. This is how I got the certs for my current setup, but unfortunately SSL-certs tend to expire rather quickly when created this way.
Luckily, certbot has automation hooks that are intended to be used in automating the process.
I haven’t got around to tinker with it yet, but I’m sure I’ll figure it out™.
The Dynadot API
This brings us to the need of automatically editing our DNS-config so the TXT record shows the desired value. And since automatic DNS-config changes are something very big companies would like to do, dynadot has an API for that! And they even let tiny €7.50/year customers like me use it for free!
In order to use the API, you need to generate a key specific for your account.
At the very top of the documentation, under |
The API can do all kinds of things, but we only need to two commands:
get_dns
and set_dns2
|
All calls to API are addressed to https://api.dynadot.com/api3.xml
, together with your key, the command you want to use and any other parameters.
Every API-command in the documentation has an example for how to use it.
From these examples, you should be able to figure out the general gist of the API-calls.
As to how to make those calls, I’m gonna be using curl
.
I’m sure there are other ways, but this one works for me in this case.
As a general sanity check, let’s run the following bash script:
api_key="your_API_key"
domain="your_registered_domain"
curl "https://api.dynadot.com/api3.xml?key=$api_key&command=get_dns&domain=$domain"
If everything is set up correctly, this will return an (unformatted) XML-response containing everything you need to know about your current DNS-config. But you will receive it as one continuous string, so throwing it into an XML-formatter is much recommended for better readability.
For example, this is my DNS-configuration at the time of writing (feel free to laugh):
<GetDnsResponse>
<GetDnsHeader>
<ResponseCode>0</ResponseCode>
<Status>success</Status>
</GetDnsHeader>
<GetDnsContent>
<NameServerSettings>
<Type>Dynadot DNS</Type>
<MainDomains>
<MainDomainRecord>
<RecordType>A</RecordType>
<Value>185.199.108.153</Value>
</MainDomainRecord>
<MainDomainRecord>
<RecordType>A</RecordType>
<Value>185.199.109.153</Value>
</MainDomainRecord>
<MainDomainRecord>
<RecordType>A</RecordType>
<Value>185.199.110.153</Value>
</MainDomainRecord>
<MainDomainRecord>
<RecordType>A</RecordType>
<Value>185.199.111.153</Value>
</MainDomainRecord>
</MainDomains>
<SubDomains>
<SubDomainRecord>
<Subhost>_github-pages-challenge-landhund</Subhost>
<RecordType>TXT</RecordType>
<Value>9dd09a1b381a1e38dc13de5987564e</Value>
</SubDomainRecord>
<SubDomainRecord>
<Subhost>www</Subhost>
<RecordType>CNAME</RecordType>
<Value>landhund.github.io</Value>
</SubDomainRecord>
<SubDomainRecord>
<Subhost>horus</Subhost>
<RecordType>CNAME</RecordType>
<Value>dreier-horus.diskstation.me</Value>
</SubDomainRecord>
<SubDomainRecord>
<Subhost>_acme-challenge.horus</Subhost>
<RecordType>TXT</RecordType>
<Value>Tf3s4ozk6-3mi2K1BDqIDc7Knm5gpuFXey2XQ48XLYw</Value>
</SubDomainRecord>
</SubDomains>
<TTL>300</TTL>
</NameServerSettings>
</GetDnsContent>
</GetDnsResponse>
Read & Save Current dynadot DNS-Config via the API-Call
With the API-call successfully tested, we are ready to start!
Unfortunately I don’t think you can store an XML-file/structure directly as a variable in bash, so we have to output the API-response into a proper, if temporary XML-file.
Simple enough, we merely have to expand our test-script a little:
api_key="your_API_key"
domain="your_registered_domain"
# Get current DNS-configuration from dynadot API and store it in a local and temporary XML-file.
curl "https://api.dynadot.com/api3.xml?key=$api_key&command=get_dns&domain=$domain" > ./api_get_response.xml
This will create a new file (overwriting any existing file) with the response stored inside it.
We can now access the contents specific nodes of the XML-file using xmllint
.
In case you don’t have it yet, you can get it via the libxml2-utils
package:
sudo apt install libxml2-utils
Using xmllint
to access nodes and their value is… completely archaic, honestly.
You’ll see why in the next chapter.
Check if API-Call was Successful
Since we want to automatically access the dynadot API, we should first check if we actually posted a correct call and got a valid response. You never know what might change over the years over which you forgot you had this script running.
The easiest way is to check if the API-response has the response-code 0
.
This value is stored at the node /GetDnsResponse/GetDnsHeader/ResponseCode
, the code to access and store it in a variable for later use looks like this (using the file we created in the last chapter):
response_code="$(echo "cat /GetDnsResponse/GetDnsHeader/ResponseCode/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
Like I said, using xmllint
is pretty archaic.[3]
But it works and that is the most important part!
Going on from here, we simply check if it’s actually 0
(or rather, if it’s anything but 0
):
if [ "$response_code" -ne 0 ]; then
echo "Error: Response Code not 0, was $response_code instead!" >logfile.log
exit 1
fi
Iterating through Main Domain Records
In order to go through all main domain records, we need to make a few preparations:
First, since the path to the nodes is quite long, we’ll store it in a variable:
maindomain_nodes="/GetDnsResponse/GetDnsContent/NameServerSettings/MainDomains"
Since we (probably) have multiple main domain records defined and need to copy/recreate each one, we have to iterate through all of them.
As far as I know, we can’t do that directly, but have to set up a for
-loop instead.
First we count the number of sub-nodes under the "/GetDnsResponse/GetDnsContent/NameServerSettings/MainDomains" node, then we loop through them and extract the type and value of each one. Then we format them into the format the API wants and add them to a string-variable, so we can easily use them later:
main_entries_count="$(xmllint --xpath "count($maindomain_nodes/*)" api_get_response.xml)"
# Initialize empty string for storing the previous main domain records in API-call format
main_records=""
# Iterate through main domain records
index=0
while [ $index -lt "$main_entries_count" ]; do
# IMPORTANT: The XML-nodes index starts at 1!
xml_index=$index+1
# Read and store the type of the current main record
type="$(echo "cat $maindomain_nodes/MainDomainRecord[$xml_index]/RecordType/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Read and store the value of the current main record
value="$(echo "cat $maindomain_nodes/MainDomainRecord[$xml_index]/Value/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Reformat the received data into the needed API-format and append it to the main_records variable
main_records+="&main_record_type$index=$type&main_record$index=$value"
((index++))
done
Iterating through Subdomain Records, Checking and Changing It
The process for the subdomain-records is almost identical, but with some important differences.
We need to copy every record except the one containing our old challenge-key. That one we need to set to the new challenge-key that we get from certbot.
And to make sure we have actually changed anything, we set up a control-flag to indicate a change. If we’ve run through every record without editing one, something went wrong.[4]
All this results into the following code:
challenge_node="domain_for_txt_record"
new_challenge_key="new_txt_record_from_certbot"
sub_entries_count="$(xmllint --xpath "count($subdomain_nodes/*)" api_get_response.xml)"
# Initialize empty string for storing the subdomain records in API-call format
sub_records=""
# Control flag to check if any records where actually changed
unchanged=1
# Iterate through subdomain records
index=1
while [ $index -le "$sub_entries_count" ]; do
# IMPORTANT: The XML-nodes index starts at 1!
xml_index=$index+1
subhost="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/Subhost/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
type="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/RecordType/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
value="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/Value/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Check if the subdomain of the current record is the one that needs to be changed
if [ "$subhost" = $challenge_node ]; then
# Overwrite the value that is stored in the TXT-record to the needed challenge key
value=$new_challenge_key
# Unset flag to indicate that a record was indeed changed
unchanged=0
fi
# Reformat the received data into the needed API-format and append it to the sub_records variable
sub_records+="&subdomain$index=$subhost&sub_record_type$index=$type&sub_record$index=$value"
((index++))
done
# Throw error and abort if no records where changed
if [ $unchanged -eq 1 ]; then
echo "Error: Challenge Node $challenge_node not found, no changes to DNS-record performed!" >logfile.log
exit 2
fi
Combine results and post Set-API Call
For easier readability, I’ve separated the combining of all those records into two steps:
api_key="your_API_key"
domain="your_registered_domain"
set_command="set_dns2"
get_command="get_dns"
api_url="https://api.dynadot.com/api3.xml"
# Combine everything into one api command/request
api_request="key=$api_key&commad=$set_command&domain=$domain$main_records$sub_records"
# Combine api-url and -request into the finished command
full_request="$api_url?$api_request"
TL;DR: The Full Code
There is a new version of this script, as described in the followup post to this one! Please refer to the one posted there for future reference. |
The following is my complete code as of now.
It’s not completely ready to be used together with certbot yet, but it would work in changing the specified subdomain record to a new value.[5]
The integration of certbot will be done in Part 2 of this series.
#!/bin/bash
api_key="your_API_key"
domain="your_registered_domain"
challenge_node="domain_for_txt_record"
new_challenge_key="new_txt_record_from_certbot"
set_command="set_dns2"
get_command="get_dns"
api_url="https://api.dynadot.com/api3.xml"
# Shorthand for the node-adress of the main domain records
maindomain_nodes="/GetDnsResponse/GetDnsContent/NameServerSettings/MainDomains"
# Shorthand for the node-adress of the sub-domain records
subdomain_nodes="/GetDnsResponse/GetDnsContent/NameServerSettings/SubDomains"
# Get current DNS-configuration from dynadot API and store it in a local and temporary xml-file.
# Commented out to prevent accidental API-calls/spam
#curl "https://api.dynadot.com/api3.xml?key=$api_key&command=get_dns&domain=$domain" > ./api_get_response.xml
# Extract API-response code
response_code="$(echo "cat /GetDnsResponse/GetDnsHeader/ResponseCode/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Check if API-call was successful (responsecode 0), break if not
if [ "$response_code" -ne 0 ]; then
echo "Error: Response Code not 0, was $response_code instead!" >logfile.log
exit 1
fi
# Count main domain records, needed to limit XML-loop
main_entries_count="$(xmllint --xpath "count($maindomain_nodes/*)" api_get_response.xml)"
# Initialize empty string for storing the previous main domain records in API-call format
main_records=""
# Iterate through main domain records
index=0
while [ $index -lt "$main_entries_count" ]; do
# IMPORTANT: The XML-nodes index starts at 1!
xml_index=$index+1
# Read and store the type of the current main record
type="$(echo "cat $maindomain_nodes/MainDomainRecord[$xml_index]/RecordType/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Read and store the value of the current main record
value="$(echo "cat $maindomain_nodes/MainDomainRecord[$xml_index]/Value/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Reformat the received data into the needed API-format and append it to the main_records variable
main_records+="&main_record_type$index=$type&main_record$index=$value"
((index++))
done
#echo $main_records
# Count subdomain records, needed to limit XML-loop
sub_entries_count="$(xmllint --xpath "count($subdomain_nodes/*)" api_get_response.xml)"
# Initialize empty string for storing the subdomain records in API-call format
sub_records=""
# Control flag to check if any records where actually changed
unchanged=1
# Iterate through subdomain records
index=1
while [ $index -le "$sub_entries_count" ]; do
# IMPORTANT: The XML-nodes index starts at 1!
xml_index=$index+1
subhost="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/Subhost/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
type="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/RecordType/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
value="$(echo "cat $subdomain_nodes/SubDomainRecord[$xml_index]/Value/text()" | xmllint --nocdata --shell api_get_response.xml | sed '1d;$d')"
# Check if the subdomain of the current record is the one that needs to be changed
if [ "$subhost" = $challenge_node ]; then
# Overwrite the value that is stored in the TXT-record to the needed challenge key
value=$new_challenge_key
# Unset flag to indicate that a record was indeed changed
unchanged=0
fi
# Reformat the received data into the needed API-format and append it to the sub_records variable
sub_records+="&subdomain$index=$subhost&sub_record_type$index=$type&sub_record$index=$value"
((index++))
done
# Throw error and abort if no records where changed
if [ $unchanged -eq 1 ]; then
echo "Error: Challenge Node $challenge_node not found, no changes to DNS-record performed!" >logfile.log
exit 2
fi
# Combine everything into one api command/request
api_request="key=$api_key&commad=$set_command&domain=$domain$main_records$sub_records"
# Combine api-url and -request into the finished command
full_request="$api_url?$api_request"
# Commented out to prevent accidental API-calls
# BE CAREFUL WHEN UNCOMMENTING IT!!!
# curl "$full_request" > ./api_set_response.xml
echo "$full_request"
Personal Closing Thoughts
The following is just me ranting about the progress, nothing important to see here. |
This has become a bigger side-side-project than I expected. As the subtitle says, I think this is at least the 3rd diversion I’ve taken since starting my Easy Wiring side project (see this post).
Getting into Bash was one special personal hell, so much so that I have split of a sizable part of this section into its own rant-y blog-post. Let’s see if I take the time to finish it at some point (Let’s go 4 diversions deep!).
Also, while writing this post over the last week (dear god, that took a long time…), I’ve completely overhauled redone the way syntax-highlighting is handled on the blog, going from rouge to prism.js, tinkering with it for a few days to get it just working and looking how I want it, only to find out my bash code breaks the regex and having to scrap all of it and finally switching to highlight.js.
Also, I think I’ve managed to somewhat break rouge
, since I can’t get it running again.
Oh well, highlight.js works pretty well for now and I think I can manually add/write the additional features I want.
Actually, that reminds me, I should write a dev-log about that journey…
sed '1d;$d'
part even does…Blog Web-Dev Bash DNS Synology API
2838 Words
March 11, 2024 (Last updated: 2024-05-07 11:50)
85d77f3 @ 2024-05-07