Living-off-the-land Dynamic DNS for Route 53
I was visiting the matter of dynamic DNS after more than a decade. Had memories of complex configuration files, having to supply login credentials to ddns scripts, finding out in the field that the home router had been failing to update the DNS record for a while, and domain registrars changing their API.
Surely the situation has improved by now, I thought. Should just be a matter of finding the latest & greatest, popping an API key in and Bob's your uncle.
Some of the old names came up. Such as ddclient and inadyn. They didn't seem to support Route 53 as a provider or have relatively up-to-date versions available via Ubuntu's repos.
Slightly concerned but hoping that I hadn't stumbled upon a post-Cloud solution yet, I chalked up criteria for the tool that would best fit my desired setup.
▢ updates via a package manager I use: apt, snap, flatpak
▢ Amazon Route 53 support
▢ maintained
I did find octodns-route53 too, but installing and updating it turned out to be fiddly.
For brevity, I'm not going to mention the tens of other forks and unmaintained GitHub projects I discovered.
updates via pkg mgr | route 53 support | maintained | |
---|---|---|---|
ddclient | no | no | yes |
inadyn | no | no | yes |
octodns-route53 | no | no | yes |
So I began to wonder if I could whip something up in a couple of hours using just aws CLI.
Would it fit my criteria?
- automatic updates?
- aws-cli was already installed via the Snap store
- if the script I wrote was only a few lines, it would be easy to troubleshoot
- AWS' API is rock solid stable
- Route 53 support?
- duh
- maintained?
- aws-cli by AWS
- the script by me
With a conscious effort to minimise dependencies, it occurred to me this tool could be made to work under living-off-the-land constraints.
Living Off The Land
Living-off-the-land, in cybersecurity context, has been described as "exploiting native tools and processes built into computer systems" [1], "intruders use legitimate software and functions available in the system" [2] and "abuse of native tools and processes on systems" [3].
Now, surely, the presence of aws
and jq
on a developer machine counts as native tools/legitimate software. A solution emerged 💡, scattered across my brain. Only had to converge the signals and transmit them via the keyboard now.
[1] https://www.ncsc.gov.uk/news/ncsc-and-partners-issue-warning-about-state-sponsored-cyber-attackers-hiding-on-critical-infrastructure-networks
[2] https://www.hhs.gov/sites/default/files/living-off-land-attacks-tlpclear.pdf
[3] https://www.cyber.gov.au/about-us/view-all-content/alerts-and-advisories/identifying-and-mitigating-living-off-the-land-techniques
Least Privilege
As much as I am a fan of Route 53, the sentiment does not extend to AWS IAM. Least privilege only as far as easily conceived was my target for this one. Attaching God rights, though, to this service not only didn't feel right but wouldn't have contained the blast radius if the credentials leaked.
After only a bit of trial and error, the following policy worked:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ddnsroute53",
"Effect": "Allow",
"Action": [
"route53:ListResourceRecordSets",
"route53:ChangeResourceRecordSets"
],
"Resource": "arn:aws:route53:::hostedzone/ZCHANGEME2CHANGEME2",
"Condition": {
"ForAllValues:StringEquals": {
"route53:ChangeResourceRecordSetsNormalizedRecordNames": "changeme1.new23d.com"
}}}]}
The ListResourceRecordSets
and ChangeResourceRecordSets
operations are limited to a specific FQDN. This is least privilege perfection, I believe!
This sort of scoping to the subdomain (that the script will be changing the A
record of) wasn't really possible in the old world with registrar-provided DNS.
Tip: If you are going to use any of the following, find & replace any mentions of changeme
& new23d
with appropriate values from your environment.
The Script
Using only bash, curl, aws and jq at /home/changeme3/opt/ddns-route53/ddns-route53.sh
we have:
#!/bin/bash
set -e
set -u
#set -x
MYIP=`curl -fsS https://4.icanhazip.com/`
R53IP=`aws route53 list-resource-record-sets --hosted-zone-id $ZONEID --query "ResourceRecordSets[?Name == '$RECORD.' && Type == 'A']" | jq -r '.[0].ResourceRecords[0].Value'`
if [ "$MYIP" != "$R53IP" ]; then
echo changing to $MYIP from $R53IP ...
aws route53 change-resource-record-sets --hosted-zone-id $ZONEID --change-batch "{\"Changes\":[{\"Action\":\"UPSERT\",\"ResourceRecordSet\":{\"Name\":\"$RECORD.\",\"Type\":\"A\",\"TTL\":60,\"ResourceRecords\":[{\"Value\":\"$MYIP\"}]}}]}"
echo ...changed
else
echo it\'s fine as it is
fi
It certainly is possible to avoid the use of curl
altogether although whether such a method will support a TLS connection to an IP address reflection service, without other dependencies, I'm not sure. (The aws
CLI could be replaced with a curl --aws-sigv4
command too, but that will make obtaining the Session Token complex.)
Whether bash, curl, aws and jq are a legit LOTL quartet, I will leave it to the reader to judge.
Configuration File
Only to separate code from configuration, these environment variables can be stashed in a safe path. This file, at /home/changeme3/opt/ddns-route53/ddns-route53.conf
, will be sourced while calling the script above.
RECORD=changeme1.new23d.com
ZONEID=ZCHANGEME2CHANGEME2
AWS_PROFILE=ddns-route53_new23d-com
Note that the FQDN is passed as configuration into the script, but needs a mention in the IAM policy as well.
There is also an assumption that there is a user on the system whose home directory has .aws/credentials
file with a profile that works and has the policy specified above attached.
Cron Job
It's taken me a few years to finally like SystemD. Used to run into limitations (and bugs) a lot but with the version shipped with Ubuntu 24.04 LTS, I can't really complain. After all, I get resource & privilege management, better cron, and logging out of the box.
At path /etc/systemd/system/ddns-route53_new23d-com.timer
, create a SystemD style Cron job – a timer:
[Unit]
Description=ddns-route53_new23d-com
[Timer]
OnCalendar=*-*-* *:00/4:00
RandomizedDelaySec=120
[Install]
WantedBy=multi-user.target
This timer triggers the service with the corresponding name (below) every 4th minute. The trigger's wall clock schedule can be previewed with the command systemd-analyze calendar "*-*-* *:00/4:00" --iterations=3
.
RandomizedDelaySec
is present because as a service owner (not of Route 53 API), I'd hate to have spikey load at every turn of the minute and then relative quiet for ~59 seconds. Cron jobs all over with a ':00' component are probably responsible for over-provisioning of everything from routing to backend compute capacity!
At path /etc/systemd/system/ddns-route53_new23d-com.service
, a
SystemD oneshot service sources the configuration file and calls our script.
[Unit]
Description=ddns-route53_new23d-com
[Service]
Type=oneshot
User=changeme3
EnvironmentFile=/home/changeme3/opt/ddns-route53/ddns-route53.conf
ExecStart=/home/changeme3/opt/ddns-route53/ddns-route53.sh
[Install]
WantedBy=multi-user.target
Linux Installation
SystemD will need to re-read the service files, and the new ones just dropped in will need to be set to start at boot:
systemctl daemon-reload
systemctl enable ddns-route53_new23d-com.service
systemctl enable ddns-route53_new23d-com.timer
To start triggering the service every 4th minute or so, let's just manually start the timer:
systemctl start ddns-route53_new23d-com.timer
Monitoring Logs
Every time the service is triggered, this command will show the output from our script.
journalctl -f -u ddns-route53_new23d-com.service
If you don't want to wait, trigger the service manually:
systemctl restart ddns-route53_new23d-com
macOS
I don't have access to a Mac so if anybody would like to send over a SystemD service & timer equivalent, I'd be happy to include them in this article with credits.
Closing Thoughts
The least privilege policy turned out to be easier to write than I had feared. I don't quite recall how I approached crafting it, but I didn't use AI in this instance.
I have thought about making this event-driven but sometimes the WAN IP of my router will change with no event or change noticed on the network stack of my machine. So, for now, I've stuck with periodic polling.
And LOTL isn't just for building evasive, malicious tools. We can build safer systems by reducing dependencies and shrinking the surface area for an attack by making creative use of native tools.
Credits
Thanks to Robert Farrimond for reviewing the piece and trying out the code in his home lab.