Packages

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

Current section

Files

Jump to
defdo_ddns lib defdo ddns api router.ex
Raw

lib/defdo/ddns/api/router.ex

defmodule Defdo.DDNS.API.Router do
@moduledoc false
use Plug.Router
alias Defdo.DDNS.Adoption
alias Defdo.DDNS.API.AuthConfig
alias Defdo.DDNS.API.AuthStore
alias Defdo.DDNS.API.DNS
plug(Plug.Parsers,
parsers: [:json],
pass: ["application/json"],
json_decoder: Jason
)
plug(:match)
plug(:dispatch)
get "/health" do
json(conn, 200, %{status: "ok"})
end
post "/v1/dns/upsert" do
with {:ok, auth_context} <- authorize(conn),
:ok <- authorize_base_domain(auth_context, conn.body_params),
{:ok, result} <- DNS.upsert_free_domain(conn.body_params) do
json(conn, 200, %{status: "ok", result: result})
else
{:error, :unauthorized} ->
json(conn, 401, %{status: "error", error: "unauthorized"})
{:error, {:validation, errors}} ->
json(conn, 422, %{status: "error", error: "validation_failed", details: errors})
{:error, {:conflict, details}} ->
json(conn, 409, %{status: "error", error: "dns_conflict", details: details})
{:error, {:upstream, reason}} ->
json(conn, 502, %{status: "error", error: "upstream_failed", details: reason})
{:error, reason} ->
json(conn, 500, %{status: "error", error: "internal_error", details: inspect(reason)})
end
end
# --- record adoption --------------------------------------------------------
#
# Read/decide over local state only. None of these touch Cloudflare — that is
# the whole point of the adoption model: discovery is read-only, and adopting
# promotes into desired state for the monitor to converge, it does not write
# DNS from here.
get "/v1/adoption" do
with {:ok, _auth} <- authorize(conn) do
state = adoption_state(conn)
json(conn, 200, %{status: "ok", state: to_string(state), entries: Adoption.list(state)})
else
{:error, :unauthorized} -> json(conn, 401, %{status: "error", error: "unauthorized"})
end
end
# Discovery has no mix on a release image, so it needs an API entry point too —
# otherwise the pending list can never be populated where DDNS actually runs.
post "/v1/adoption/refresh" do
with {:ok, _auth} <- authorize(conn),
domain when is_binary(domain) <- conn.body_params["domain"],
{:ok, result} <- Adoption.refresh(domain) do
json(conn, 200, %{status: "ok", result: result, entries: Adoption.list(:pending)})
else
{:error, :unauthorized} ->
json(conn, 401, %{status: "error", error: "unauthorized"})
nil ->
json(conn, 422, %{
status: "error",
error: "validation_failed",
details: %{domain: "required"}
})
{:error, reason} ->
# A Cloudflare read failure, not our fault and nothing written.
json(conn, 502, %{status: "error", error: "discovery_failed", details: inspect(reason)})
end
end
post "/v1/adoption/:id/accept" do
decide(conn, id, &Adoption.accept/2)
end
post "/v1/adoption/:id/reject" do
decide(conn, id, &Adoption.reject/2)
end
match _ do
json(conn, 404, %{status: "error", error: "not_found"})
end
defp adoption_state(conn) do
conn = fetch_query_params(conn)
case conn.query_params["state"] do
s when s in ~w(pending accepted rejected all) -> String.to_existing_atom(s)
_ -> :pending
end
end
defp decide(conn, id, fun) do
with {:ok, _auth} <- authorize(conn) do
meta = Map.take(conn.body_params || %{}, ["by", "note"])
case fun.(id, meta) do
{:ok, entry} ->
json(conn, 200, %{status: "ok", entry: entry})
{:error, :not_found} ->
json(conn, 404, %{status: "error", error: "not_found"})
{:error, {:promotion_failed, reason}} ->
json(conn, 409, %{status: "error", error: "promotion_failed", details: inspect(reason)})
{:error, reason} ->
json(conn, 500, %{status: "error", error: "internal_error", details: inspect(reason)})
end
else
{:error, :unauthorized} -> json(conn, 401, %{status: "error", error: "unauthorized"})
end
end
defp authorize(conn) do
clients = AuthStore.get_clients()
client_id = provided_client_id(conn)
cond do
map_size(clients) == 0 ->
authorize_global_token(conn)
is_binary(client_id) ->
authorize_client(conn, clients, client_id)
true ->
# Compatibility path:
# when clients are configured but no x-client-id is sent, allow a valid global token.
authorize_global_token(conn)
end
end
defp authorize_client(conn, clients, client_id) when is_binary(client_id) do
with %{token: expected_token} = client <- Map.get(clients, client_id),
true <- secure_compare_tokens?(provided_token(conn), expected_token) do
{:ok,
%{mode: :client, client_id: client_id, allowed_base_domains: client.allowed_base_domains}}
else
_ -> {:error, :unauthorized}
end
end
defp authorize_global_token(conn) do
case configured_token() do
token when is_binary(token) ->
if secure_compare_tokens?(provided_token(conn), token) do
{:ok, %{mode: :token}}
else
{:error, :unauthorized}
end
_ ->
{:error, :unauthorized}
end
end
defp authorize_base_domain(%{mode: :client, allowed_base_domains: allowed_domains}, params)
when is_map(params) do
case extract_base_domain(params) do
nil ->
:ok
base_domain when is_binary(base_domain) ->
if Enum.member?(allowed_domains, base_domain) do
:ok
else
{:error, {:validation, %{"base_domain" => "not allowed for client"}}}
end
_ ->
{:error, {:validation, %{"base_domain" => "not allowed for client"}}}
end
end
defp authorize_base_domain(_auth_context, _params), do: :ok
defp parse_bearer_token(nil), do: nil
defp parse_bearer_token(value) when is_binary(value) do
case String.split(value, " ", parts: 2) do
["Bearer", token] -> AuthConfig.normalize_token(token)
_ -> nil
end
end
defp provided_token(conn) do
conn
|> Plug.Conn.get_req_header("authorization")
|> List.first()
|> parse_bearer_token()
|> case do
nil ->
conn
|> Plug.Conn.get_req_header("x-api-token")
|> List.first()
|> AuthConfig.normalize_token()
bearer ->
bearer
end
end
defp provided_client_id(conn) do
conn
|> Plug.Conn.get_req_header("x-client-id")
|> List.first()
|> normalize_client_id()
end
defp secure_compare_tokens?(provided_token, expected_token)
when is_binary(provided_token) and is_binary(expected_token) and
byte_size(provided_token) == byte_size(expected_token) do
Plug.Crypto.secure_compare(provided_token, expected_token)
end
defp secure_compare_tokens?(_, _), do: false
defp configured_token do
case AuthConfig.from_app_env() do
{:ok, %{token: token}} -> token
_ -> nil
end
end
defp extract_base_domain(params) do
raw_base_domain =
cond do
Map.has_key?(params, "base_domain") ->
Map.get(params, "base_domain")
Map.has_key?(params, :base_domain) ->
Map.get(params, :base_domain)
true ->
nil
end
AuthConfig.normalize_base_domain(raw_base_domain)
end
defp normalize_client_id(nil), do: nil
defp normalize_client_id(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
client_id -> client_id
end
end
defp normalize_client_id(_), do: nil
defp json(conn, status, payload) do
body = Jason.encode!(payload)
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> Plug.Conn.send_resp(status, body)
end
end