#!/usr/bin/env ruby # ----- # # Copyright (c) 2024 mahesh # # Permission is hereby granted, free of charge, to any person obtaining a copy of # this software and associated documentation files (the "Software"), to deal in # the Software without restriction, including without limitation the rights to # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies # of the Software, and to permit persons to whom the Software is furnished to do # so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. # # ----- require 'pp' require 'optparse' require 'net/http' require 'uri' require 'json' require 'yaml' require 'logger' # ------------------ # Helper Classes # ------------------ class CloudflareDynDNSUpdater attr_accessor :options, :myip, :config, :cache def initialize(options) @logger = Logger.new(STDOUT) @options = options @config = read_config_file @logger.level = @options[:debug] ? Logger::DEBUG : Logger::INFO @logger.info($0) { "Starting..." } @query_info = Hash.new @cache_updated = false if @options[:api_key].empty? @logger.error "Need Cloudflare API key to proceed" exit 1 end end def run() # # Load the cache so we know hosts and their IPs were updated # in the past # @cache = read_cache_file @logger.debug @cache.inspect # # Get the Public IP (WAN). We will compare this with the IP # in the cache to know if the IP has changed and needs to be # updated in Cloudflare's DNS # @myip = get_public_ip @logger.info "Public IP: #{@myip}" # # Query DNS records # process_dns # Write updated cache if @cache_updated write_cache_file end end def report() @logger.info($0) { "Done." } end private # # Read configuration file, which contains the list of hosts and # their credentials. Configuration file uses the YAML format # and has the following template: # # ----------- # hosts: # - host: hostone.com # zone_id: # - host: hosttwo.com # zone_id: # - ... # ----------- # # Default location of the configuration file is: # # ~/.cfdyndns.yaml # # Alternate location may be provided with [-f|--config_file] option # def read_config_file() if File.exists?(options[:config_file]) return YAML.load_file(options[:config_file]) else @logger.error "Config file [#{options[:config_file]}] missing!" end end # # To avoid frequent queries to Cloudflare, store the status of # latest update to a cache file. Cloudflare is updated only if # our public IP has changed. # # Default location of cache file is: # # ~/.cfdyndns.cache # # Alternate location may be provided with [-c|--cache_file] option # def read_cache_file() # # Update to Cloudflare may be forced by forgetting cached # values. This is controlled by the [-u|--force_update] option # if options[:force_update] if File.exists?(options[:cache_file]) File.delete(options[:cache_file]) end end # # Load the cache file # return File.exists?(options[:cache_file]) ? YAML.load_file(options[:cache_file]) : {} end # # Store IPs from current update into cache # def write_cache_file() @logger.debug @cache.inspect @logger.info "Writing updated cache file" File.open(options[:cache_file], "w") {|f| f.write(@cache.to_yaml)} end # # Acquire the public IP from an external service # def get_public_ip() return Net::HTTP.get(URI('http://ipinfo.io/ip')) end # # Query Cloudflare for Domains info # def process_dns() # # Handle each host in the config file at a time # @config['hosts'].each {|h| if @cache[h['host']] && @myip.eql?(@cache[h['host']]['ip']) @logger.info "Skipping #{h['host']} - Already pointing to #{@myip}" else query_sts_good = query_dns(h) if query_sts_good update_sts_good = update_dns(h['host']) end end } end def query_dns(host) sts_good = false url = "https://api.cloudflare.com/client/v4/zones/#{host['zone_id']}/dns_records" @logger.debug "Querying Cloudflare for host [#{host['host']}] - #{url}" uri = URI(url) Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| request = Net::HTTP::Get.new uri request.add_field 'Content-Type', 'application/json' request.add_field 'Authorization', "Bearer #{@options[:api_key]}" request.add_field 'User-Agent', "#{@options[:user_agent]}" response = http.request request # Net::HTTPResponse object sts_good = handle_query(JSON.parse(response.body)) end return sts_good end def handle_query(body) if body['success'] body['result'].each {|entry| if entry['type'].eql?('A') @query_info[entry['name']] = entry return true end } return false else body['errors'].each {|err| @logger.error "Error status returned #{err['code']}: #{err['message']}" } return false end end # # Update Cloudflare if IP has changed # def update_dns(host) sts_good = false # # Handle each host in the config file at a time # if @query_info.has_key?(host) # Use information from query response when possible q_info = @query_info[host] url = "https://api.cloudflare.com/client/v4/zones/#{q_info['zone_id']}/dns_records/#{q_info['id']}" @logger.debug "Updating Cloudflare for host [#{host['host']}] - #{url}" uri = URI(url) request_body = { 'type' => q_info['type'], 'name' => q_info['name'], 'content' => @myip } @logger.debug request_body.to_json Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| request = Net::HTTP::Patch.new uri request.add_field 'Content-Type', 'application/json' request.add_field 'Authorization', "Bearer #{@options[:api_key]}" request.add_field 'User-Agent', "#{@options[:user_agent]}" request.body = request_body.to_json response = http.request request # Net::HTTPResponse object sts_good = handle_update(JSON.parse(response.body)) end return sts_good else @logger.error "Did not find query information for host #{host}" end return sts_good end def handle_update(body) if body['success'] entry = body['result'] @logger.info "Successfully updated DNS entry type #{entry['type']} - #{entry['name']} - #{entry['content']}" @cache[entry['name']] = {'ip' => entry['content']} @cache_updated = true return true else body['errors'].each {|err| @logger.error "Error status returned #{err['code']}: #{err['message']}" } return false end end end # ------------------- # Options parsing # ------------------- # # Default values of command line options # options = { :config_file => "#{Dir.home}/.cfdyndns.yaml", :cache_file => "#{Dir.home}/.cfdyndns.cache", :user_agent => 'HeshApps Cloudflare Dynamic DNS Updater v1.0', :force_update => false, :debug => false, :api_key => '' } # # Get command line options # op = OptionParser.new do |opts| opts.banner = "Usage: cloudflare_dyndns_update.rb [options]" opts.on("-d", "--debug", "Enable debug messages") do |debug| options[:debug] = debug end opts.on("-u", "--force_update", "Force DNS update") do |force_update| options[:force_update] = force_update end opts.on("-f", "--config_file FILE", "Location of configuration file") do |file| options[:config_file] = file end opts.on("-c", "--cache_file FILE", "Location of cache file") do |file| options[:cache_file] = file end opts.on("-k", "--api_key KEY", "Cloudflare API authorization key") do |key| options[:api_key] = key end opts.on("-h", "--help", "Display this help") do |help| options[:help] = help end end op.parse! if options[:help] puts op.help exit end # ------------------ # Execution part # ------------------ updater = CloudflareDynDNSUpdater.new(options) updater.run updater.report # # Mahesh Asolkar, 2024 # vim: ai ts=2 sts=2 et sw=2 filetype=ruby