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
I'm getting so sick of reading about how the EU is messing with Apple and iOS. I'm no Apple... Shortcuts is nearly good