diff --git a/.formatter.exs b/.formatter.exs new file mode 100644 index 0000000..525446d --- /dev/null +++ b/.formatter.exs @@ -0,0 +1,4 @@ +# Used by "mix format" +[ + inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] +] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8886356 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# The directory Mix will write compiled artifacts to. +/_build/ + +# If you run "mix test --cover", coverage assets end up here. +/cover/ + +# The directory Mix downloads your dependencies sources to. +/deps/ + +# Where 3rd-party dependencies like ExDoc output generated docs. +/doc/ + +# Ignore .fetch files in case you like to edit your project deps locally. +/.fetch + +# If the VM crashes, it generates a dump, let's ignore it too. +erl_crash.dump + +# Also ignore archive artifacts (built via "mix archive.build"). +*.ez + +# Ignore package tarball (built via "mix hex.build"). +dough-*.tar + diff --git a/README.md b/README.md index 4a0438b..1b3e810 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ -# Edoh +# Dough **TODO: Add description** ## Installation If [available in Hex](https://hex.pm/docs/publish), the package can be installed -by adding `edoh` to your list of dependencies in `mix.exs`: +by adding `dough` to your list of dependencies in `mix.exs`: ```elixir def deps do [ - {:edoh, "~> 0.1.0"} + {:dough, "~> 0.1.0"} ] end ``` Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) and published on [HexDocs](https://hexdocs.pm). Once published, the docs can -be found at [https://hexdocs.pm/edoh](https://hexdocs.pm/edoh). +be found at [https://hexdocs.pm/dough](https://hexdocs.pm/dough). diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..47e489a --- /dev/null +++ b/config/config.exs @@ -0,0 +1,30 @@ +# This file is responsible for configuring your application +# and its dependencies with the aid of the Mix.Config module. +use Mix.Config + +# This configuration is loaded before any dependency and is restricted +# to this project. If another project depends on this project, this +# file won't be loaded nor affect the parent project. For this reason, +# if you want to provide default values for your application for +# 3rd-party users, it should be done in your "mix.exs" file. + +# You can configure your application as: +# +# config :dough, key: :value +# +# and access this configuration in your application as: +# +# Application.get_env(:dough, :key) +# +# You can also configure a 3rd-party app: +# +# config :logger, level: :info +# + +# It is also possible to import configuration files, relative to this +# directory. For example, you can emulate configuration per environment +# by uncommenting the line below and defining dev.exs, test.exs and such. +# Configuration from the imported file will override the ones defined +# here (which is why it is important to import them last). +# +# import_config "#{Mix.env}.exs" diff --git a/lib/dough.ex b/lib/dough.ex new file mode 100644 index 0000000..5ed7edb --- /dev/null +++ b/lib/dough.ex @@ -0,0 +1,36 @@ +defmodule Dough do + @moduledoc false + + use Application + + import Supervisor.Spec + import Cachex.Spec + + + def start(_type, _args) do + # List all child processes to be supervised + children = [ + # Starts a worker by calling: Dough.Worker.start_link(arg) + # {Dough.Worker, arg}, + # Plug.Adapters.Cowboy.child_spec(:https, Dough.Router, [], port: 8331, keyfile: "priv/ssl/localhost.key", certfile: "priv/ssl/localhost.crt", otp_app: :dough) + worker(Cachex, [:dough, [ + expiration: expiration( + default: :timer.seconds(6000), + interval: :timer.seconds(300), + lazy: true) + ]]), + + {Plug.Adapters.Cowboy2, scheme: :https, plug: Dough.Router, options: [ + port: 8331, + otp_app: :dough, + keyfile: "priv/ssl/localhost.key", + certfile: "priv/ssl/localhost.crt" + ]} + ] + + # See https://hexdocs.pm/elixir/Supervisor.html + # for other strategies and supported options + opts = [strategy: :one_for_one, name: Dough.Supervisor] + Supervisor.start_link(children, opts) + end +end diff --git a/lib/dough/context.ex b/lib/dough/context.ex new file mode 100644 index 0000000..042c58e --- /dev/null +++ b/lib/dough/context.ex @@ -0,0 +1,26 @@ +defmodule Dough.RequestContext do + require Logger + + + defstruct [:start, :close, :lookup, :cache, :ttl] + + def ctx_start(ctx) do + Map.put(ctx, :start, System.monotonic_time(:milliseconds)) + end + def ctx_close(ctx) do + Map.put(ctx, :close, System.monotonic_time(:milliseconds)) + end + def ctx_lookup(ctx, dns_record) do + Map.put(ctx, :lookup, dns_record.qdlist |> List.first()) + end + def ctx_cachehit(ctx, v) do + Map.put(ctx, :cache, v) + end + def ctx_ttl(ctx, ttl) do + Map.put(ctx, :ttl, ttl) + end + + def ctx_log_out(ctx) do + Logger.info "#{ctx.lookup.type} - #{ctx.lookup.domain} - TTL #{ctx.ttl} | #{ctx.cache} - #{ctx.close - ctx.start}ms" + end +end diff --git a/lib/dough/dohplug.ex b/lib/dough/dohplug.ex new file mode 100644 index 0000000..1287b9a --- /dev/null +++ b/lib/dough/dohplug.ex @@ -0,0 +1,82 @@ +defmodule Dough.DoHPlug do + import Socket.Datagram, only: [send!: 3, recv!: 1] + import Plug.Conn + import Dough.TTL + import Dough.RequestContext + + require Logger + require DNS + + def init(options), do: options + + def call(conn, _opts) do + + ctx = %Dough.RequestContext{} |> ctx_start() + + dns_message = case parse_doh(conn) do + {:ok, msg} -> msg + {:error, _} -> + Logger.error conn.method + raise Dough.NotAllowed, "" + end + + {cache_hit, ttl, dns_resp} = fetch_dns(dns_message) + + decoded = DNS.Record.decode(dns_message) + + ctx = ctx_lookup(ctx, decoded) + |> ctx_cachehit(cache_hit) + |> ctx_ttl(ttl) + |> ctx_close() + + Dough.RequestContext.ctx_log_out(ctx) + + conn + # support for [RFC5861] / cache control extensions + |> put_resp_header("cache-control", "max-age=#{ttl}, stale-while-revalidate=#{ttl * 2}, stale-if-error=#{ttl * 10}") + |> put_resp_header("content-type", content_type(conn)) + |> send_resp(200, dns_resp) + end + + def content_type(conn) do + case List.keyfind(conn.req_headers, "accept", 0) do + {"accept", accept} -> accept + nil -> "application/dns-message" + end + end + + def fetch_dns(dns_message) do + case ttlcache_lookup(dns_message) do + nil -> + resp = handoff_dns(dns_message) + ttl = ttl_extract(resp) + ttlcache_set(dns_message, resp, ttl) + + {:miss, ttl, resp} + result -> + ttl = ttl_extract(result) + {:hit, ttl, result} + end + + end + + def parse_doh(conn) do + conn = fetch_query_params conn + case conn.method do + "GET" -> {:ok, Base.url_decode64!(conn.query_params["dns"], padding: false, ignore: :whitespace)} + "POST" -> + {:ok, body, _conn} = read_body(conn) + {:ok, body} + _ -> {:error, nil} + end + end + + def handoff_dns(dns_message) do + client = Socket.UDP.open! + send!(client, dns_message, {"8.8.8.8", 53}) + {data, _server} = recv!(client) + data + end + +end + diff --git a/lib/dough/exceptions.ex b/lib/dough/exceptions.ex new file mode 100644 index 0000000..7704bd9 --- /dev/null +++ b/lib/dough/exceptions.ex @@ -0,0 +1,13 @@ +defmodule Dough.NotAllowed do + defexception [plug_status: 405, message: "Method Not Allowed"] +end + +defmodule Dough.ServerError do + defexception [plug_status: 500, message: "Server Error"] +end + +defimpl Plug.Exception, for: [Dough.NotAllowed, Dough.ServerError] do + def status(exc) do + exc.plug_status + end +end diff --git a/lib/dough/router.ex b/lib/dough/router.ex new file mode 100644 index 0000000..3177dfc --- /dev/null +++ b/lib/dough/router.ex @@ -0,0 +1,18 @@ +defmodule Dough.Router do + use Plug.Router + use Plug.ErrorHandler + + if Mix.env == :dev do + use Plug.Debugger + end + + require Logger + + plug :match + plug :dispatch + + get "/", do: send_resp(conn, 200, "") + match "/dns-query", to: Dough.DoHPlug + match _, do: send_resp(conn, 404, "oopsie") + +end diff --git a/lib/dough/ttlcache.ex b/lib/dough/ttlcache.ex new file mode 100644 index 0000000..e5a1a39 --- /dev/null +++ b/lib/dough/ttlcache.ex @@ -0,0 +1,42 @@ +defmodule Dough.TTL do + + require Logger + + def ttlcache_lookup(dns_message) do + case Cachex.get(:dough, _hashify_dns(dns_message)) do + {:ok, nil} -> nil + {:ok, value} -> value + {:error, :nocache} -> raise Dough.ServerError, message: "Cache failure" + end + end + + def ttlcache_set(dns_message, dns_result, ttl) do + Cachex.put(:dough, _hashify_dns(dns_message), dns_result, ttl: :timer.seconds(ttl)) + dns_result + end + + def ttl_extract(dns_result) do + decoded = DNS.Record.decode(dns_result) + answers = {decoded.anlist |> List.first(), decoded.nslist |> List.first()} + + case answers do + {nil, nss} -> + # {:dns_rr, 'd.akamai.net', :soa, :in, 0, 901, {'n0d.akamai.net', 'hostmaster.akamai.com', 1534350643, 1000, 1000, 1000, 1800}, :undefined, [], false}. + nss + |> elem(6) + # {'n0d.akamai.net', 'hostmaster.akamai.com', 1534350643, 1000, 1000, 1000, 1800} + |> elem(6) + + {ans, nil} -> + ans.ttl + {ans, _} -> + ans.ttl + end + end + + def _hashify_dns(dns_message) do + :crypto.hash(:sha256, dns_message) + |> Base.encode16 + end + +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..c3448c3 --- /dev/null +++ b/mix.exs @@ -0,0 +1,31 @@ +defmodule Dough.MixProject do + use Mix.Project + + def project do + [ + app: :dough, + version: "0.1.0", + elixir: "~> 1.6", + start_permanent: Mix.env() == :prod, + deps: deps() + ] + end + + # Run "mix help compile.app" to learn about applications. + def application do + [ + extra_applications: [:logger, :cowboy, :plug, :dns, :cachex], + mod: {Dough, []} + ] + end + + # Run "mix help deps" to learn about dependencies. + defp deps do + [ + {:cowboy, "~> 2.4.0"}, + {:plug, "~> 1.5"}, + {:dns, "~> 2.1.0"}, + {:cachex, "~> 3.0.3"} + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..0b81821 --- /dev/null +++ b/mix.lock @@ -0,0 +1,12 @@ +%{ + "cachex": {:hex, :cachex, "3.0.3", "4e2d3e05814a5738f5ff3903151d5c25636d72a3527251b753f501ad9c657967", [:mix], [{:eternal, "~> 1.2", [hex: :eternal, repo: "hexpm", optional: false]}, {:unsafe, "~> 1.0", [hex: :unsafe, repo: "hexpm", optional: false]}], "hexpm"}, + "cowboy": {:hex, :cowboy, "2.4.0", "f1b72fabe9c8a5fc64ac5ac85fb65474d64733d1df52a26fad5d4ba3d9f70a9f", [:rebar3], [{:cowlib, "~> 2.3.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.5.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"}, + "cowlib": {:hex, :cowlib, "2.3.0", "bbd58ef537904e4f7c1dd62e6aa8bc831c8183ce4efa9bd1150164fe15be4caa", [:rebar3], [], "hexpm"}, + "dns": {:hex, :dns, "2.1.0", "4777fe07ae3060c1d5d75024f05c26d7e11fa701d48a6edb9fc305d24cd12c8c", [:mix], [{:socket, "~> 0.3.13", [hex: :socket, repo: "hexpm", optional: false]}], "hexpm"}, + "eternal": {:hex, :eternal, "1.2.0", "e2a6b6ce3b8c248f7dc31451aefca57e3bdf0e48d73ae5043229380a67614c41", [:mix], [], "hexpm"}, + "mime": {:hex, :mime, "1.3.0", "5e8d45a39e95c650900d03f897fbf99ae04f60ab1daa4a34c7a20a5151b7a5fe", [:mix], [], "hexpm"}, + "plug": {:hex, :plug, "1.6.2", "e06a7bd2bb6de5145da0dd950070110dce88045351224bd98e84edfdaaf5ffee", [:mix], [{:cowboy, "~> 1.0.1 or ~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, + "ranch": {:hex, :ranch, "1.5.0", "f04166f456790fee2ac1aa05a02745cc75783c2bfb26d39faf6aefc9a3d3a58a", [:rebar3], [], "hexpm"}, + "socket": {:hex, :socket, "0.3.13", "98a2ab20ce17f95fb512c5cadddba32b57273e0d2dba2d2e5f976c5969d0c632", [:mix], [], "hexpm"}, + "unsafe": {:hex, :unsafe, "1.0.0", "7c21742cd05380c7875546b023481d3a26f52df8e5dfedcb9f958f322baae305", [:mix], [], "hexpm"}, +} diff --git a/priv/ssl/localhost.crt b/priv/ssl/localhost.crt new file mode 100644 index 0000000..fbcc548 --- /dev/null +++ b/priv/ssl/localhost.crt @@ -0,0 +1,18 @@ +-----BEGIN CERTIFICATE----- +MIIC5TCCAc2gAwIBAgIJANmlfWvetApSMA0GCSqGSIb3DQEBCwUAMBQxEjAQBgNV +BAMMCWxvY2FsaG9zdDAeFw0xODA4MTQxNjIwNDdaFw0xODA5MTMxNjIwNDdaMBQx +EjAQBgNVBAMMCWxvY2FsaG9zdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoC +ggEBALkU8/+EXKF7gplnEvJ6CXs8fKmHZNrEnH5OGZ32E2f1c+ursblHo30zLtn9 +ahrQUyMn/IaZsN82xIfhHFrkOfSkZ1w1+2n6hhXjNlu7s81tXrhf3LKzWm40SXmE +lWbcCaxUinYYd11VXO3FJ3Puxie8NAm10RAYksSP+Q3zLjZB4vfD/wT5011+9ds4 +shr22ldUfG5Isr6/ByI/agkTrHm67YRKwdQ0AR7kaTlHgzKKcWaebH9sdPI4owuZ +p5VH3nFqA3rtn8x01zYv1u/iN3P0iHl/T9UV2jy0OMq/lJlQR473Ht2Pn/ZiiX+v +X9vYbI+y+0P3p29c3zVOPTr8XOMCAwEAAaM6MDgwFAYDVR0RBA0wC4IJbG9jYWxo +b3N0MAsGA1UdDwQEAwIHgDATBgNVHSUEDDAKBggrBgEFBQcDATANBgkqhkiG9w0B +AQsFAAOCAQEANDXhuI9N/4KX15N7ZUd9CD7ljUg9ESNyt1U68r+9229pFZP9JBIw +Z6XxN3jMR9DSCmiD62qWcbRVdV3GyTbogTwe5N6DATcnIdsz+4ToqXxzXOduolMc +dzrk7emYYfIVZQ61vCOoSCWYOnQtPTKU7ZetnwFIzBzgqr/ASZjYY2+z7IUnGBSr +xF6GDh9FMCmsvb5IEaVgbT8aWeo3TwF5UhNJkUnjp+Fr7kKP74EABiUkSNJulUVX +QFd0PX80I2IbQYKZZNh0Q19ykodDk6gKmhw7ZVVDSCfkpiQIUzFjlCcZZLl2Rn7V +WK8NU0vqx5/0aNS9ZTir7efp1bkYi6w4+Q== +-----END CERTIFICATE----- diff --git a/priv/ssl/localhost.key b/priv/ssl/localhost.key new file mode 100644 index 0000000..9d06246 --- /dev/null +++ b/priv/ssl/localhost.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC5FPP/hFyhe4KZ +ZxLyegl7PHyph2TaxJx+Thmd9hNn9XPrq7G5R6N9My7Z/Woa0FMjJ/yGmbDfNsSH +4Rxa5Dn0pGdcNftp+oYV4zZbu7PNbV64X9yys1puNEl5hJVm3AmsVIp2GHddVVzt +xSdz7sYnvDQJtdEQGJLEj/kN8y42QeL3w/8E+dNdfvXbOLIa9tpXVHxuSLK+vwci +P2oJE6x5uu2ESsHUNAEe5Gk5R4MyinFmnmx/bHTyOKMLmaeVR95xagN67Z/MdNc2 +L9bv4jdz9Ih5f0/VFdo8tDjKv5SZUEeO9x7dj5/2Yol/r1/b2GyPsvtD96dvXN81 +Tj06/FzjAgMBAAECggEAYNrONVEXCIqR9aUzDSFABPXKZw0rgjCRlKdaUIeN8EFK +wHHBN6x9qe82/WzYMeADIcqzI90Z6jXG8zSimg4FJjlCvcaiIvuvX5TfxXGczkwF +3YaNABdLo5BJQwZXCNTtWvmC2/pBvN4HG4ao9spleDXNJae4GCxD4glV1C8GZ+1+ +4vakqKsUwU9LqQBC7JwrnDFwlLSutGE7yqpblzZeyO3ByzK8aKfktkZXurfshumk +LYzvE01e7cb4oOAOmMlEnpQ61y22G12xaEj3HU20/WH3zkS7ijjmxWawErcYxgjh +KzfEeLg9ERGFQf73fhod1oCkaSjqw/SgnwY2+3kGwQKBgQDgfTcySZNpGCJRm5jH +CT+j7vubWixzBwcNdILW78V1SaS7s1RMJXgq6bPoyPqKAi1tTsVvpOr3YmuTMexJ +WG5gDZWWKcpc+ZMvZJGffdoTL4ZbYBrv1mlT0MmC4i/iHIyUNsHsaTcpa1lSvn3g +mUykg+izOWgOvt9bbwLg+IbIjQKBgQDTD62Gz3dkvo3PXGDUV0p4F4O4IKvHgKlX +//qDM+yJPfdru6uUWdiUWvdtvWVXyW15cKLZ28lPvIsl4u2Q7ga3t8iIdkbP3RB/ +G6W+xgPxyOMPazphrLYSd4VF3zEzjcVg6B1uw27uvZDXEHbBgZfvGGbGFkj7x9fz +Do8IaBR3LwKBgQCVmnzdU3EcmYvNbO88vWoe+tXMqyRyJ93IVrwXN0UVK9XPjOwB +rvrNRl+yI7XroRXbAaLMC1DXOkzMlHmOS5OLCaKFpyYIHf67l78AViOto39bh8mL +ygz5YWvZgJk+i54X7AICZf/v521omjBTLHaKMKo0Pm6dXRCG4408lgNkxQKBgHJy +UOUGALbHQTxM2lCqGL5v+cPRK3SdxrXqHxwf/sYYrN5lQE/MPE2N2hdOmPJ2Tf+I +3TWHIW00Tru3hpyNBWV/qaSdhh5WwAg8pK6Cz0a/aKhGu7yVG5F97+2IbjSHTp0S +oEscfD3G2xBTZCHftNQ4hhopoV+t6OJqZZLoZRiPAoGBANZd2Xj8SCENVjVQS8+r +CoMwZb8lpisdUh1aqpyL7KokvWA2/c6/cEd5A0I+1o63T7N7YUJv+CV3qBeC0u10 +OIYkn98uzjBMekTYJs/RSDV8nRu766x5KiGqezftH8fMeIMn/fJ9XEIAk9aSMT+F +KjzV3G0y/iLykshNL+vDW3Ou +-----END PRIVATE KEY----- diff --git a/test/dough_tests.exs b/test/dough_tests.exs new file mode 100644 index 0000000..4aa0aff --- /dev/null +++ b/test/dough_tests.exs @@ -0,0 +1,8 @@ +defmodule DoughTest do + use ExUnit.Case + doctest Dough + + test "greets the world" do + assert Dough.hello() == :world + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..869559e --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1 @@ +ExUnit.start()