forked from thurloat/dough
parent
aa139e9618
commit
320617fa26
@ -0,0 +1,4 @@ |
|||||||
|
# Used by "mix format" |
||||||
|
[ |
||||||
|
inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"] |
||||||
|
] |
@ -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 |
||||||
|
|
@ -1,21 +1,21 @@ |
|||||||
# Edoh |
# Dough |
||||||
|
|
||||||
**TODO: Add description** |
**TODO: Add description** |
||||||
|
|
||||||
## Installation |
## Installation |
||||||
|
|
||||||
If [available in Hex](https://hex.pm/docs/publish), the package can be installed |
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 |
```elixir |
||||||
def deps do |
def deps do |
||||||
[ |
[ |
||||||
{:edoh, "~> 0.1.0"} |
{:dough, "~> 0.1.0"} |
||||||
] |
] |
||||||
end |
end |
||||||
``` |
``` |
||||||
|
|
||||||
Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) |
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 |
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). |
||||||
|
|
||||||
|
@ -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" |
@ -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 |
@ -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 |
@ -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 |
||||||
|
|
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -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"}, |
||||||
|
} |
@ -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----- |
@ -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----- |
@ -0,0 +1,8 @@ |
|||||||
|
defmodule DoughTest do |
||||||
|
use ExUnit.Case |
||||||
|
doctest Dough |
||||||
|
|
||||||
|
test "greets the world" do |
||||||
|
assert Dough.hello() == :world |
||||||
|
end |
||||||
|
end |
@ -0,0 +1 @@ |
|||||||
|
ExUnit.start() |
Loading…
Reference in new issue