Skip to content

Instantly share code, notes, and snippets.

@IsoLinearCHiP
Forked from sixtyfive/update-ddns.rb
Last active October 20, 2025 15:22
Show Gist options
  • Select an option

  • Save IsoLinearCHiP/d6bc594143102e6acd131fc65c080f64 to your computer and use it in GitHub Desktop.

Select an option

Save IsoLinearCHiP/d6bc594143102e6acd131fc65c080f64 to your computer and use it in GitHub Desktop.
DIY dDNS service script using Hetzner's DNS API
#!/usr/bin/env ruby
# on OpenRC systems, place into e.g. /etc/periodic/15min
# (or create /etc/periodic/1min and add line to root's crontab)
# on systemd systems, install it as a timer
# create/find these in Hetzner's DNS admin interface at https://dns.hetzner.com/
API_TOKEN_NAME="..."
API_TOKEN="..."
ZONES=["...", "..."] # TLD; must be registered at https://robot.hetzner.com/domain
RECORDS=["...", "..."] # list of subdomains, also specified there
API_BASE = "https://dns.hetzner.com/api/v1"
# will make the script return a little faster if specified
# ZONE_ID="..."
# RECORD_ID="..."
require 'json'
require 'socket'
require 'net/http'
require 'optparse'
def GET(uri)
headers = {
"Auth-API-Token" => API_TOKEN,
"Accept" => "application/json"
}
Net::HTTP.get(URI(uri), headers)
end
def POST(uri, body)
headers = {
"Auth-API-Token" => API_TOKEN,
"Accept" => "application/json",
"Content-Type" => "application/json"
}
res = Net::HTTP.post(URI(uri), body, headers)
res.body
end
def PUT(uri, body)
uri = URI(uri)
hostname = uri.hostname
req = Net::HTTP::Put.new(uri)
req["Auth-API-Token"] = API_TOKEN
req["Accept"] = "application/json"
req.body = body
req.content_type = 'application/json'
res = Net::HTTP.start(hostname, uri.port, :use_ssl => true) do |http|
http.request(req)
end
res.body
end
def main(zone_id, record_id)
ZONES.each do |zone|
zone_id ||= (
json = JSON.parse(GET("https://dns.hetzner.com/api/v1/zones"))
zones_json = json.delete('zones')
zones_json.find{|x| x['name'] == zone}['id']
)
json = JSON.parse(GET("https://dns.hetzner.com/api/v1/records?zone_id=#{zone_id}"))
records_json = json.delete('records')
current_wan_ip = Socket.getifaddrs
.map{|iface| [iface.name, iface.addr.ip_address] if iface.addr.ipv4? if iface.addr}
.compact
.find{|x| x[0]=='ppp0'}[1]
if record_id then
data = JSON.generate({
"value": "#{current_wan_ip}",
"ttl": 60,
"type": "A",
"name": "#{record}",
"zone_id": "#{zone_id}"
})
PUT("https://dns.hetzner.com/api/v1/records/#{record_id}", data)
else
RECORDS.each do |record|
record_json = records_json.find{|x| x['name']==record}
url = "https://dns.hetzner.com/api/v1/records"
data = JSON.generate({
"value": "#{current_wan_ip}",
"ttl": 60,
"type": "A",
"name": "#{record}",
"zone_id": "#{zone_id}"
})
record_id ||= record_json['id'] # must exist!
if record_id then
PUT("https://dns.hetzner.com/api/v1/records/#{record_id}", data)
else
POST("https://dns.hetzner.com/api/v1/records", data)
end
end
end
end
end
def test(zone_id, record_id)
puts zone_id
puts record_id
puts GET("https://httpbin.org/get")
puts POST("https://httpbin.org/post", JSON.generate({foo: "bar"}))
puts PUT("https://httpbin.org/put", JSON.generate({foo: "bar"}))
end
options = {}
OptionParser.new do |parser|
parser.banner = "Usage: update-ddns.rb [options]"
parser.on("-t", "--test", "Run tests") do |v|
options[:test] = v
end
parser.on("-z ZONE", "--zone ZONE", "Specify zone id") do |v|
options[:zone_id] = v
end
parser.on("-r RECORD", "--record RECORD", "Specify record id") do |v|
options[:record_id] = v
end
end.parse!
if options[:test] then
test(options[:zone_id], options[:record_id])
else
main(options[:zone_id], options[:record_id])
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment