From 73e6444a0055675180e162259ec03c306a0ac64c Mon Sep 17 00:00:00 2001 From: Mahesh Asolkar Date: Sun, 4 Feb 2024 11:48:23 -0800 Subject: [PATCH] Initial Commit --- cloudflare_dyndns_update.rb | 330 ++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100755 cloudflare_dyndns_update.rb diff --git a/cloudflare_dyndns_update.rb b/cloudflare_dyndns_update.rb new file mode 100755 index 0000000..fa544fd --- /dev/null +++ b/cloudflare_dyndns_update.rb @@ -0,0 +1,330 @@ +#!/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