Let's implement dynamic DNS using Crystal and Cloudflare
03/12/2024
My dyndns client was broken, and they keep getting me to choose pictures of buses to renew my free domain, so I've taken matters into my own hands. I've never been happy with dyndns - it's fine but I hate having to tell them I still want the domain every 30 days. I know it's just a chance for them to make me pay, but they underestimate what a cheapskate I am!
I initially implemented this in Ruby, but sharing gems across users and making everything still actually work was making me want to throw my laptop out of the window and burn my house down, so I reimplemented it in Crystal. It's been a long time since I wrote any Crystal; fortunately the docs are great, and the syntax is basically Ruby.
It's pretty simple in terms of approach. We have a cron that checks our current IP, and fails if it can't find one (like when my internet is frequently down), then it finds the DNS record we're going to update, and if our current IP is different to the one Cloudflare has, we change it, and then let Cloudflare figure out the rest. This will mean a maximum 5 minute downtime if my IP changes, but it doesn't seem to change that often so it's not a huge problem.
require "http/client"
require "http/headers"
require "json"
# Load some config and defaults from an rc file
rc = File.expand_path("~/.dyndnsrc", home: true)
unless File.exists?(rc)
log("~/.dyndnsrc missing.", STDERR)
exit 1
end
# cloudflare auth headers
config = Hash(String, String).from_json(File.read(rc))
headers = HTTP::Headers.new.merge!({
"x-auth-email" => config["email"].to_s,
"x-auth-key" => config["key"].to_s,
})
# log messages and interpolate the date
def log(message : String, target : Crystal::System::FileDescriptor)
parts = [Time.local.to_s, message]
target.puts(parts.join(": "))
end
# get our current IP
ipreq = HTTP::Client.get(config["ipendpoint"])
unless ipreq.status_code == 200
log("Problem getting IP: #{ipreq.body}", STDERR)
exit 1
end
# we're going to assume that no external entities update this record
# and as such we're not going to hit cloudflare every time we check
# we'll check every 6 hours just in case something external changed
# this ip address
ip = ipreq.body
should_recheck = config["cfage"]? ? Time::Format::ISO_8601_DATE_TIME.parse(config["cfage"].to_s) < 6.hours.ago : true
if should_recheck
# check the record with cloudflare
dnsreq = HTTP::Client.get("https://api.cloudflare.com/client/v4/zones/#{config["zone"]}/dns_records", headers: headers)
unless dnsreq.status_code == 200
log("DNS record list failed: #{dnsreq.body}", STDERR)
exit 1
end
record = JSON.parse(dnsreq.body)["result"].as_a.find { |rec| rec["type"] == "A" && rec["name"] == config["domain"] }
if record.nil?
log("Cannot find existing A record: #{config["domain"]}", STDERR)
exit 1
end
# cache the results we got from cloudflare
config["cfid"] = record["id"].to_s
config["cfip"] = record["content"].to_s
config["cfage"] = Time::Format::ISO_8601_DATE_TIME.format(Time.local)
File.open(rc, "w") { |file| file.print(config.to_json) }
end
# only do the update if what cloudflare has is different to what we have
if config["cfip"] == ip
log("No change needed", STDOUT)
exit
else
# update cloudflare
# this won't create a new record so you need to create something first
log("Updating: #{config["cfip"]} to #{ip}", STDOUT)
body = { content: ip }.to_json
update = HTTP::Client.patch(
"https://api.cloudflare.com/client/v4/zones/#{config["zone"]}/dns_records/#{config["cfid"]}",
body: body,
headers: headers.dup.merge!({"content-type" => "application/json"}),
)
if update.status_code == 200
log("Updated", STDOUT)
else
log("Update failed: #{update.body}", STDERR)
end
end
And in your ~/.dyndnsrc
file:
{
"ipendpoint": "https://ifconfig.me/ip",
"domain": "THE DOMAIN",
"zone": "THE CLOUDFLARE ZONE ID",
"email": "YOUR CLOUDFLARE EMAIL",
"key": "YOUR CLOUDFLARE API TOKEN"
}
And the crontab I'm using to run this every 5 minutes:
# every 5 minutes, run and redirect our output
*/5 * * * * root /usr/local/bin/dyndns >> /var/log/dyndns.log 2>&1