2026-03-11 18:02:38 +00:00
2026-03-11 18:02:38 +00:00

Dynamic DNS written with Bash

Motivation

Due to hosting my nginx webserver at home my IP is subject to change as my isp does not afford me a static one

Said webserver hosts tobiastime.xyz and the pages you are currently viewing

Initially I wanted to create a Bash script to interact with Namecheap's API allowing me to automatically update my A record when needs be

However Namecheap charges $50 to interact with their API, and I have heard even if you cough up the money it is very poor and limited

Consequently I began to run my own nameservers (ns1/2.tobiastime.xyz) for full autonomy and control over my domain utilizing PowerDNS as the backend

Dependencies

  • SSH/SFTP
  • PowerDNS
  • Bash

Prerequisites

For security purposes all of my standard ssh keys are password protected

However persistently storing the password to a protected key in non-volatile memory and allowing it to be used for automated scripts is a difficult and risky endeavour

Consequently I generated a new SSH key without password protection and linked it to a user with nologin shell

Said user is appropriately named jaileduser and their sole purpose is to transfer the public IP of my NGINX server to my master nameserver

Set up on the nameserver

Create jaileduser as a system user

useradd -r -s /usr/sbin/nologin jaileduser

Create jaileduser's home directory and give root ownership

Root must be given ownership due to chroot modifications we will make in the SSH config

mkdir /home/jaileduser
chown root:root /home/jaileduser

Then edit their home directory in /etc/passwd so it looks something like

jaileduser:x:999:999::/home/jaileduser:/usr/sbin/nologin

Create a subdirectory within jaileduser's home directory where the file storing the public IP of the NGINX server will reside

And give jaileduser only read/execute permissions for the directory (no write so they cannot create more files)

mkdir /home/jaileduser/nginx
chown jaileduser:jaileduser /home/jaileduser/nginx
chmod 500 /home/jaileduser/nginx

Create the file within that directory with which the public IP of the nginx server will be stored

Then give jaileduser read and write permissions for the file so it can be modified via SFTP

Then create a file storing the current IP within the records of PowerDNS

touch /home/jaileduser/nginx/homeip
chmod 600 /home/jaileduser/nginx/homeip
touch /home/jaileduser/record/recordip
echo "9.8.7.6 (use the actual ip)" > /home/jaileduser/nginx/recordip

Lastly we generate the SSH key for jaileduser so it can be used to authenticate and transfer files using sftp

And create the appropriate files within jaileduser's home directory

The private key will need to be transfered and stored onto the nginx server

mkdir /home/jaileduser/.ssh
touch /home/jaileduser/.ssh/authorized_keys
ssh-keygen -f jailedkey
cat jailedkey.pub > /home/jaileduser/.ssh/authorized_keys
rm jailedkey.pub

Due to the user having no login shell if the less secure key were ever compromised it would not provide an attack vector for remote code execution on the server

However a user with nologin shell is not able to be used to transfer files with sftp without first making some modifications to the sshd configuration file

Changes to be made in /etc/ssh/sshd_config:
Match User jaileduser
    ChrootDirectory /home/jaileduser
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no
    PermitTunnel no

In reference to the above changes

  • Match user applies the following configuration changes only to jaileduser

  • ChrootDirectory sets their root directory to their home directory when connected via ssh so they will not be able to navigate or view directories outside of it

  • ForceCommand internal-sftp stops the user starting an interactive shell or executing any other commands over ssh

  • The rest are standardized deny permissions to further bolster security and prevent jaileduser bypassing the restrictions in place

With all of this done the preliminary steps are complete and we can place the scripts on the nginx server and nameserver

Script ran on NGINX server to send public IP to nameserver:

#!/bin/bash

#define path to private key
keypath=/path/to/jailed/key
#define where you want your publicip file to be stored on the nginx server
thetext=/home/user/publicip

ip1=$(curl icanhazip.com 2>/dev/null)

#backup for if curl icanhazip.com fails utilizing dig and opendns

backupcheck() {

local ip2=$(dig +short myip.opendns.com @resolver1.opendns.com) 2>/dev/null \
&& echo "$ip2" > "$thetext"

}

#waiting 60 seconds before trying the dig fall back method 
#this is in case the initial failure was due to a network blip
[ -z "$ip1" ] && sleep 60 && backupcheck || echo "$ip1" > "$thetext"

#update scp to use the actual remote ip of your nameserver
#the -z check paired with the logical OR does nothing if both curl and dig failed to return an ip
[ -z "$(cat "$thetext")" ] || scp -i "$keypath" "$thetext" jaileduser@1.2.3.4:/nginx/homeip

Script ran on master nameserver to update DNS records:

#!/bin/bash

homeip=/home/jaileduser/nginx/homeip
recordip=/home/jaileduser/nginx/recordip
#define where you want the bashddns log to live
bashddnslog=/var/log/bashddns.log

changetime() {

local newip=$(cat "$homeip")

#here i use @ because i want to update the root of my domain and a TTL of 3600 (standard for A records)
pdnsutil replace-rrset yourdomain.name @ A 3600 "$newip"

#increasing the serial number in the SOA so the slave nameserver is notified of changes to the domain
pdnsutil increase-serial yourdomain.name

#updating the record ip file with the new ip
cat "$homeip" > "$recordip"

#the following last line is optional
#it uses ssmtp to send an email notification when changes to the A record are made
#ssmtp or any other command line compatible mail sending utility will work

echo -e "Subject: domain.name updated A record\nYour home ip has changed!" | ssmtp mail@site.name

}

#though we made sure on the nginx server not to send the file if it was empty
#we will implement a double check here for redundancy
#only run the following *if* homeip is not empty 

if ! [ -z "$(cat "$homeip")" ] ; then

#if homeip/recordip files are the same do nothing, else run changetime
#log all actions to a centralized file for auditing

diff "$homeip" "$recordip" && \
echo "A record matches homeip at $(date -u) no action taken" >> "$bashddnslog" \
&& exit 0 \
|| changetime && echo "changed A record at $(date -u) to "$(cat "$recordip")"" >> "$bashddnslog"

else
echo "no action taken due to homeip file being empty at $(date -u)" >> "$bashddnslog"
fi

Scheduling automatic execution

Finally we need to schedule these scripts to automatically run using cronjobs

I will configure the DDNS to run its check once every hour matching up with the 3600 TTL

This means the public ip of the nginx server at home will be checked and the record will be changed if needed every hour

I have made a 2 minute gap between when the IP sending script runs on the NGINX server and when the record updating script runs on the nameserver

This is to afford time for transferring the files and in case the dig fall back method runs, which first waits for 60 seconds

By default without further permission modifications root must run the script on the nameserver as root is required to modify the SQLite3 database PowerDNS utilizes

In /etc/crontab for the nginx server place:

0 * * * *    youruser /path/to/ip/sending/script

On the master nameserver in /etc/crontab place:

2 * * * *    root /path/to/bashddns/script

Rotating logs

Assuming your system has logrotate installed (as most modern distros do

And you don't want a giant wall of text singular log file for all bashddns actions on the master nameserver

A log rotate entry can be made as follows:

touch /etc/logrotate.d/bashddns
nano /etc/logrotate.d/bashddns

Insert these configuration options:

/var/log/bashddns.log {
 rotate 7
 daily
 compress
 missingok
 notifempty
 create 644 youruser youruser
}

This will keep logs for a week, rotate them daily, compress old ones, and ensure the log file is readable and writebale by your user

Description
No description provided
Readme 43 KiB
Languages
Shell 100%