defmodule Defdo.DDNS.API.DNS do @moduledoc false require Logger alias Defdo.DDNS.DesiredStateStore @spec upsert_free_domain(map()) :: {:ok, map()} | {:error, term()} def upsert_free_domain(params) when is_map(params) do with {:ok, fqdn} <- fetch_required_string(params, "fqdn"), {:ok, base_domain} <- fetch_required_string(params, "base_domain"), :ok <- ensure_zone_membership(fqdn, base_domain), {:ok, zone_id} <- fetch_zone_id(base_domain), {:ok, desired_record} <- build_desired_record(params, fqdn, base_domain), {:ok, result} <- upsert_cname(zone_id, desired_record) do {:ok, Map.put(result, :declared, declare(desired_record, base_domain))} end rescue error -> Logger.error("dns_api_upsert_failed reason=#{Exception.message(error)}") {:error, {:upstream, :request_failed}} end def upsert_free_domain(_), do: {:error, {:validation, %{"params" => "must be an object"}}} # A record this deployment just created is managed from birth. Without this, # every domain provisioned through the API showed up as unmanaged on the next # inventory and an operator had to adopt a record the platform itself had # created — the API would have been manufacturing exactly the drift adoption # exists to clean up. # # Never fatal. The DNS record already exists at this point, so failing the # response would report as failed something that succeeded. The caller gets # `declared: false` and the log says why. defp declare(desired_record, base_domain) do case DesiredStateStore.declare(Map.put(desired_record, "domain", base_domain)) do {:ok, _doc} -> true {:error, :disabled} -> false {:error, reason} -> Logger.warning( "dns_api_declare_failed name=#{desired_record["name"]} reason=#{inspect(reason)}" ) false end end defp upsert_cname(zone_id, desired_record) do existing_records = ddns_module().list_dns_records(zone_id, name: desired_record["name"]) cname_records = Enum.filter(existing_records, &(&1["type"] == "CNAME")) conflicting_records = Enum.reject(existing_records, &(&1["type"] == "CNAME")) cond do conflicting_records != [] -> conflict_types = conflicting_records |> Enum.map(& &1["type"]) |> Enum.uniq() {:error, {:conflict, %{types: conflict_types}}} cname_records == [] -> case ddns_module().create_dns_record(zone_id, desired_record) do {true, record} when is_map(record) -> {:ok, %{action: "created", record: record}} _ -> {:error, {:upstream, :create_failed}} end true -> updates = ddns_module().input_for_update_cname_records(cname_records, desired_record) case updates do [] -> {:ok, %{action: "noop", record: List.first(cname_records)}} _ -> apply_updates(zone_id, updates) end end end defp apply_updates(zone_id, updates) do results = updates |> Enum.map(&ddns_module().apply_update(zone_id, &1)) case Enum.find(results, fn {true, _} -> false _ -> true end) do nil -> last_record = results |> List.last() |> case do {true, record} -> record _ -> nil end {:ok, %{action: "updated", record: last_record}} _ -> {:error, {:upstream, :update_failed}} end end defp fetch_zone_id(base_domain) do case ddns_module().get_zone_id(base_domain) do zone_id when is_binary(zone_id) and zone_id != "" -> {:ok, zone_id} _ -> {:error, {:upstream, :zone_not_found}} end end defp build_desired_record(params, fqdn, base_domain) do case get_string(params, "record_type", "CNAME") |> String.upcase() do "CNAME" -> proxied = get_boolean(params, "proxied", default_proxied()) {:ok, %{ "type" => "CNAME", "name" => fqdn, "content" => resolve_target(params, base_domain), "proxied" => proxied, "ttl" => resolve_ttl(params, proxied) }} type -> {:error, {:validation, %{"record_type" => "unsupported value #{type}"}}} end end defp resolve_target(params, base_domain) do target = get_string(params, "target", default_target()) |> String.trim() cond do target == "@" -> base_domain String.contains?(target, ".") -> String.trim_trailing(target, ".") true -> "#{target}.#{base_domain}" end end defp resolve_ttl(params, proxied) do if proxied do 1 else case Map.get(params, "ttl") || Map.get(params, :ttl) do value when is_integer(value) and value > 0 -> value value when is_binary(value) -> case Integer.parse(String.trim(value)) do {int, ""} when int > 0 -> int _ -> 300 end _ -> 300 end end end defp ensure_zone_membership(fqdn, base_domain) do if fqdn == base_domain or String.ends_with?(fqdn, ".#{base_domain}") do :ok else {:error, {:validation, %{"fqdn" => "must belong to base_domain"}}} end end defp fetch_required_string(params, field) when is_binary(field) do value = get_string(params, field, nil) case normalize_hostname(value) do nil -> {:error, {:validation, %{field => "can't be blank"}}} hostname -> {:ok, hostname} end end defp get_boolean(params, key, default) when is_binary(key) do case fetch_param(params, key) do value when is_boolean(value) -> value value when is_binary(value) -> normalized = value |> String.trim() |> String.downcase() normalized in ~w(true 1 yes on) _ -> default end end defp get_string(params, key, default) when is_binary(key) do case fetch_param(params, key) do value when is_binary(value) -> value nil -> default value -> to_string(value) end end defp fetch_param(params, key) when is_binary(key) do case key_to_atom(key) do nil -> Map.get(params, key) atom_key -> fetch_string_or_atom_key(params, key, atom_key) end end defp key_to_atom(key) do case key do "fqdn" -> :fqdn "base_domain" -> :base_domain "record_type" -> :record_type "target" -> :target "proxied" -> :proxied "ttl" -> :ttl _ -> nil end end defp fetch_string_or_atom_key(params, string_key, atom_key) do cond do Map.has_key?(params, string_key) -> Map.get(params, string_key) Map.has_key?(params, atom_key) -> Map.get(params, atom_key) true -> nil end end defp normalize_hostname(value) when is_binary(value) do value |> String.trim() |> String.trim_trailing(".") |> String.downcase() |> case do "" -> nil hostname -> hostname end end defp normalize_hostname(_), do: nil defp default_target do :defdo_ddns |> Application.get_env(Defdo.DDNS.API, []) |> Keyword.get(:default_target, "@") end defp default_proxied do :defdo_ddns |> Application.get_env(Defdo.DDNS.API, []) |> Keyword.get(:default_proxied, true) end defp ddns_module do :defdo_ddns |> Application.get_env(Defdo.DDNS.API, []) |> Keyword.get(:ddns_module, Defdo.Cloudflare.DDNS) end end