Skip to content

Instantly share code, notes, and snippets.

@fnando
Created April 1, 2022 15:45
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save fnando/fda47701c68028fe3b46b38bc49ebabf to your computer and use it in GitHub Desktop.
Save fnando/fda47701c68028fe3b46b38bc49ebabf to your computer and use it in GitHub Desktop.
Dynamic DNS with DNSimple and Ruby
# frozen_string_literal: true
require "net/http"
require "json"
module Net
def HTTP.patch(url, data, header = nil)
start(url.hostname, url.port, use_ssl: url.scheme == "https") do |http|
http.patch(url, data, header)
end
end
end
class DDNS
attr_reader :cache_file, :cache_dir, :dnsimple_api_key, :records
def initialize(dnsimple_api_key:, cache_dir:, records:)
@dnsimple_api_key = dnsimple_api_key
@cache_dir = cache_dir
@cache_file = File.join(cache_dir, "ddns.json")
@records = records
end
def cache
@cache ||= if File.file?(cache_file)
JSON.parse(File.read(cache_file), symbolize_names: true)
else
{}
end
end
def run
loop do
system "clear"
set_dnsimple_account_id
unless cache[:dnsimple_account_id]
log("no dnsimple account id, waiting 30s before retrying.")
sleep(30)
next
end
check_public_ip_address
rescue StandardError => error
log("ERROR: #{error.class}: #{error.message}")
ensure
log("waiting 60s before new check")
sleep 60
end
end
def check_public_ip_address
details = JSON.parse(Net::HTTP.get(URI("https://ifconfig.me/all.json")))
ip_address = details.fetch("ip_addr")
log("ip address is #{ip_address.inspect}")
records.each {|record| set_dns(record, ip_address) }
end
def set_dns(record, ip_address)
entries = dnsimple_get(
"#{cache[:dnsimple_account_id]}/zones/#{record[:domain]}/records?type=A"
).fetch(:data)
entry = entries.find {|e| e[:name] == record[:name].to_s }
if entry
if entry[:content] == ip_address
log("dns record is already pointing to #{ip_address}")
else
update_dns_record(entry, ip_address)
end
else
create_dns_record(record, ip_address)
end
end
def create_dns_record(record, ip_address)
log("creating dns record for #{record[:name]}.#{record[:domain]}")
response = dnsimple_post(
"#{cache[:dnsimple_account_id]}/zones/#{record[:domain]}/records",
name: record[:name] || "",
type: "A",
content: ip_address,
ttl: record[:ttl] || 60
)
log("result when creating dns record: #{response.code}")
end
def update_dns_record(entry, ip_address)
log("updating dns record for #{entry[:name]}.#{entry[:zone_id]}")
response = dnsimple_patch(
"#{cache[:dnsimple_account_id]}/zones/#{entry[:zone_id]}/records/#{entry[:id]}",
content: ip_address
)
log("result when updating dns record: #{response.code}")
end
def set_dnsimple_account_id
return if cache[:dnsimple_account_id]
account_id = dnsimple_get("accounts").dig(:data, 0, :id)
cache[:dnsimple_account_id] = account_id
update_cache(dnsimple_account_id: account_id)
end
def log(*messages)
puts "[#{Time.now}] #{messages.join(' ')}"
end
def update_cache(**values)
@cache = cache.merge(values)
File.open(cache_file, "w") do |io|
io << JSON.pretty_generate(cache)
end
end
def dnsimple_get(path)
url = File.join("https://api.dnsimple.com/v2", path)
log("GET #{url}")
JSON.parse(
Net::HTTP.get(
URI(url),
"Authorization" => "Bearer #{dnsimple_api_key}"
),
symbolize_names: true
)
end
def dnsimple_post(path, data)
url = File.join("https://api.dnsimple.com/v2", path)
log("POST #{url}")
Net::HTTP.post(
URI(url),
JSON.dump(data),
"Authorization" => "Bearer #{dnsimple_api_key}",
"Content-Type" => "application/json"
)
end
def dnsimple_patch(path, data)
url = File.join("https://api.dnsimple.com/v2", path)
log("PATCH #{url}")
Net::HTTP.patch(
URI(url),
JSON.dump(data),
"Authorization" => "Bearer #{dnsimple_api_key}",
"Content-Type" => "application/json"
)
end
end
DDNS.new(
dnsimple_api_key: "<DNS SIMPLE API KEY>",
cache_dir: File.dirname(__FILE__),
records: [
# name is the subdomain (optional).
{name: "test", domain: "fnando.com"}
]
).run
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment