Packages

Cloudflare DDNS updater for A/AAAA/CNAME records with proxy-aware synchronization.

Current section

Files

Jump to
defdo_ddns lib defdo cloudflare monitor.ex
Raw

lib/defdo/cloudflare/monitor.ex

defmodule Defdo.Cloudflare.Monitor do
@moduledoc """
Keep watching the ip
"""
require Logger
import Defdo.Cloudflare.DDNS
use GenServer
defmodule State do
@moduledoc false
defstruct refetch_every: nil
end
# server
@impl true
def init(%State{} = state) do
{:ok, state, {:continue, :start_monitor}}
end
@impl true
def handle_continue(:start_monitor, state) do
execute_monitor()
Process.send_after(self(), :keep_monitoring, state.refetch_every)
{:noreply, state}
end
@impl true
def handle_call(:checkup, _from, state) do
result = execute_monitor()
{:reply, result, state}
end
@impl true
def handle_info(:keep_monitoring, state) do
execute_monitor()
Process.send_after(self(), :keep_monitoring, state.refetch_every)
{:noreply, state}
end
# client
def start_link(state \\ []) do
refetch_every = Keyword.get(state, :refetch_every, :timer.minutes(5))
GenServer.start_link(__MODULE__, %State{refetch_every: refetch_every}, name: __MODULE__)
end
def checkup do
GenServer.call(__MODULE__, :checkup)
end
def checkup_once do
execute_monitor()
end
defp execute_monitor do
Logger.info("Executing checkup...")
get_all_cloudflare_config_domains()
|> Enum.map(&safe_process/1)
rescue
error ->
# Second line of defence. A checkup must never take the monitor down: the
# supervisor would restart it straight into the same failing call and, after
# the restart intensity is exhausted, shut the whole application down. A
# failed checkup is logged and retried on the next tick instead.
message = "Error - checkup aborted: #{Exception.message(error)}"
Logger.error(message)
[message]
end
defp safe_process(domain) do
process(domain)
rescue
error ->
message = "Error - checkup failed for domain=#{domain}: #{Exception.message(error)}"
Logger.error(message)
[message]
end
defp process(domain) do
Logger.info("Processing domain: #{domain}")
zone_id = get_zone_id(domain)
if is_nil(zone_id) do
message = "Error - unable to resolve Cloudflare zone id for domain=#{domain}"
Logger.error(message)
[message]
else
a_records_to_monitor =
if domain_configured?(domain, :domain_mappings) do
records_to_monitor(domain, :domain_mappings)
else
[]
end
aaaa_records_to_monitor =
if domain_configured?(domain, :aaaa_domain_mappings) do
records_to_monitor(domain, :aaaa_domain_mappings)
else
[]
end
a_record_name_set = MapSet.new(a_records_to_monitor)
aaaa_record_name_set = MapSet.new(aaaa_records_to_monitor)
local_ipv4 =
if MapSet.size(a_record_name_set) > 0 do
get_current_ipv4()
else
nil
end
local_ipv6 =
if MapSet.size(aaaa_record_name_set) > 0 do
get_current_ipv6()
else
nil
end
if MapSet.size(a_record_name_set) > 0 and is_nil(local_ipv4) do
Logger.error("Unable to detect public IPv4 address; A records cannot be synchronized")
end
if MapSet.size(aaaa_record_name_set) > 0 and is_nil(local_ipv6) do
Logger.warning(
"Unable to detect public IPv6 address; AAAA records will be skipped for this cycle"
)
end
configured_cname_records = get_cname_records_for_domain(domain)
cname_record_names = configured_cname_records |> Enum.map(& &1["name"]) |> MapSet.new()
dns_records_to_monitor =
a_records_to_monitor
|> Kernel.++(aaaa_records_to_monitor)
|> Kernel.++(MapSet.to_list(cname_record_names))
|> Enum.uniq()
auto_create_missing_records = get_cloudflare_key(:auto_create_missing_records)
# Note: Making separate API calls for each DNS record due to Cloudflare API deprecation
# of comma-separated name filtering (deprecated 2025-02-21)
online_dns_records =
dns_records_to_monitor
|> Enum.flat_map(fn record_name ->
records = list_dns_records(zone_id, name: record_name)
created_records =
maybe_create_missing_ip_records(
zone_id,
record_name,
records,
local_ipv4,
local_ipv6,
a_record_name_set,
aaaa_record_name_set,
cname_record_names,
auto_create_missing_records
)
records ++ created_records
end)
ip_dns_records =
online_dns_records
|> Enum.filter(&(&1["type"] in ~w(A AAAA)))
ip_result =
ip_dns_records
|> input_for_update_dns_records(%{"A" => local_ipv4, "AAAA" => local_ipv6})
|> Enum.map(fn input ->
{success, result} = apply_update(zone_id, input)
message =
if success do
"Success - #{result["name"]} DNS record updated (ip=#{result["content"]}, proxied=#{result["proxied"]})"
else
"Error - #{inspect(input)}"
end
if success do
Logger.info(message)
else
Logger.error(message)
end
message
end)
cname_result = sync_cname_records(zone_id, configured_cname_records)
result = ip_result ++ cname_result
# Re-read records after updates to evaluate final state.
final_dns_records =
dns_records_to_monitor
|> Enum.flat_map(fn record_name -> list_dns_records(zone_id, name: record_name) end)
|> Enum.filter(&(&1["type"] in ~w(A AAAA CNAME)))
log_advanced_certificate_warnings(domain, final_dns_records)
ssl_mode = get_zone_ssl_mode(zone_id)
expected_proxied = get_cloudflare_key(:proxy_a_records, false)
posture = evaluate_domain_posture(final_dns_records, ssl_mode, expected_proxied)
posture_message = log_domain_posture(domain, posture)
result =
if result == [] do
message = "Nothing to do"
Logger.info(message)
[message, posture_message]
else
result ++ [posture_message]
end
Logger.info("Checkup completed")
result
end
end
defp create_missing_ip_records(
zone_id,
record_name,
local_ipv4,
local_ipv6,
a_record_name_set,
aaaa_record_name_set,
existing_records
) do
proxied = get_cloudflare_key(:proxy_a_records, false)
ttl = if proxied, do: 1, else: 300
existing_record_types =
existing_records
|> Enum.map(&Map.get(&1, "type", ""))
|> MapSet.new()
record_types_to_create =
[]
|> maybe_add_missing_record_type(
"A",
record_name,
a_record_name_set,
local_ipv4,
existing_record_types
)
|> maybe_add_missing_record_type(
"AAAA",
record_name,
aaaa_record_name_set,
local_ipv6,
existing_record_types
)
Enum.flat_map(record_types_to_create, fn {record_type, record_ip} ->
Logger.info("Creating missing DNS record: #{record_type} #{record_name}")
record_data = %{
"type" => record_type,
"name" => record_name,
"content" => record_ip,
"ttl" => ttl,
"proxied" => proxied
}
case create_dns_record(zone_id, record_data) do
{true, result} ->
Logger.info(
"Created DNS record: #{record_type} #{record_name} with promotional comment"
)
[result]
{false, _} ->
Logger.error("Failed to create DNS record: #{record_type} #{record_name}")
[]
end
end)
end
defp maybe_add_missing_record_type(
acc,
record_type,
record_name,
monitored_names,
detected_ip,
existing_record_types
) do
if MapSet.member?(monitored_names, record_name) and
not MapSet.member?(existing_record_types, record_type) do
if is_binary(detected_ip) and detected_ip != "" do
[{record_type, detected_ip} | acc]
else
Logger.warning(
"Skipping #{record_type} auto-create for #{record_name}: no detected #{record_type} public address"
)
acc
end
else
acc
end
end
defp maybe_create_missing_ip_records(
zone_id,
record_name,
records,
local_ipv4,
local_ipv6,
a_record_name_set,
aaaa_record_name_set,
cname_record_names,
auto_create_missing_records
) do
cond do
MapSet.member?(cname_record_names, record_name) ->
if Enum.empty?(records) do
Logger.warning("DNS record '#{record_name}' not found in Cloudflare")
Logger.info(
"Skipping A auto-create for '#{record_name}' because it is managed as CNAME"
)
end
[]
Enum.empty?(records) and auto_create_missing_records ->
Logger.warning("DNS record '#{record_name}' not found in Cloudflare")
create_missing_ip_records(
zone_id,
record_name,
local_ipv4,
local_ipv6,
a_record_name_set,
aaaa_record_name_set,
records
)
Enum.empty?(records) ->
Logger.warning("DNS record '#{record_name}' not found in Cloudflare")
Logger.info("Set AUTO_CREATE_DNS_RECORDS=true to auto-create missing records")
[]
not auto_create_missing_records ->
[]
Enum.any?(records, &(&1["type"] == "CNAME")) ->
Logger.warning(
"Skipping A/AAAA auto-create for '#{record_name}': CNAME record already exists"
)
[]
true ->
create_missing_ip_records(
zone_id,
record_name,
local_ipv4,
local_ipv6,
a_record_name_set,
aaaa_record_name_set,
records
)
end
end
defp sync_cname_records(_zone_id, []), do: []
defp sync_cname_records(zone_id, desired_cname_records) do
desired_cname_records
|> Enum.flat_map(&sync_cname_record(zone_id, &1))
end
defp sync_cname_record(zone_id, desired_record) do
record_name = desired_record["name"]
existing_records = list_dns_records(zone_id, name: record_name)
cname_records = Enum.filter(existing_records, &(&1["type"] == "CNAME"))
conflicting_records = Enum.reject(existing_records, &(&1["type"] == "CNAME"))
cond do
conflicting_records != [] ->
conflicting_types =
conflicting_records
|> Enum.map(& &1["type"])
|> Enum.uniq()
|> Enum.join(",")
message =
"Error - cannot manage CNAME #{record_name}: conflicting DNS record type(s) exist (#{conflicting_types})"
Logger.error(message)
[message]
cname_records == [] ->
case create_dns_record(zone_id, desired_record) do
{true, result} ->
message =
"Success - #{result["name"]} CNAME record created (target=#{result["content"]}, proxied=#{result["proxied"]})"
Logger.info(message)
[message]
{false, _} ->
message = "Error - failed to create CNAME record: #{record_name}"
Logger.error(message)
[message]
end
true ->
cname_records
|> input_for_update_cname_records(desired_record)
|> Enum.map(fn input ->
{success, result} = apply_update(zone_id, input)
message =
if success do
"Success - #{result["name"]} CNAME record updated (target=#{result["content"]}, proxied=#{result["proxied"]})"
else
"Error - #{inspect(input)}"
end
if success do
Logger.info(message)
else
Logger.error(message)
end
message
end)
end
end
defp log_domain_posture(domain, posture) do
status = posture.overall |> to_string() |> String.upcase()
summary =
"[HEALTH][#{status}] domain=#{domain} ssl_mode=#{posture.ssl_mode} edge_tls=#{posture.edge_tls} " <>
"proxied=#{posture.proxied_count}/#{posture.records_total} dns_only=#{posture.dns_only_count} " <>
"proxy_mismatch=#{posture.proxy_mismatch_count} hairpin_risk=#{posture.hairpin_risk}"
case posture.overall do
:green ->
Logger.info(summary)
:yellow ->
Logger.warning(
summary <> " recommendation=Use Full (strict) and proxied records for web apps"
)
:red ->
Logger.error(summary <> " recommendation=Set Cloudflare SSL/TLS mode to Full (strict)")
end
summary
end
defp log_advanced_certificate_warnings(domain, records) do
deep_hosts =
records
|> Enum.map(&Map.get(&1, "name"))
|> Enum.reject(&is_nil/1)
|> Enum.filter(&requires_advanced_certificate?(&1, domain))
|> Enum.uniq()
proxied_deep_hosts =
records
|> Enum.filter(
&(Map.get(&1, "proxied", false) and requires_advanced_certificate?(&1["name"], domain))
)
|> Enum.map(& &1["name"])
|> Enum.uniq()
excluded_deep_hosts =
deep_hosts
|> Enum.filter(&proxy_excluded?/1)
|> Enum.uniq()
if proxied_deep_hosts != [] do
Logger.warning(
"[CERT][ACM] domain=#{domain} proxied_hosts=#{Enum.join(proxied_deep_hosts, ",")} " <>
"may not be covered by Cloudflare Universal SSL and can require Advanced Certificate Manager."
)
end
if excluded_deep_hosts != [] do
Logger.warning(
"[CERT][ACM] domain=#{domain} excluded_hosts=#{Enum.join(excluded_deep_hosts, ",")} " <>
"matched CLOUDFLARE_PROXY_EXCLUDE; keeping DNS only helps avoid edge TLS handshake failures without Advanced Certificate Manager."
)
end
end
end