Packages

A test Acme server for generating SSL Certificates

Current section

Files

Jump to
acme_ex lib router.ex
Raw

lib/router.ex

defmodule AcmeEx.Router do
use Plug.Builder
require Logger
alias Plug.Conn
alias AcmeEx.{Account, Order, Header, Jws, Nonce, Cert}
@moduledoc """
The test ACME server. Add this to your supervision tree
by calling
AcmeEx.Router.child_spec()
Available opts include:
* `adapter` - Defaults based on which version of cowboy
* `port` - Defaults to `4002`
* `site` - Defaults to `http://localhost:{port}`
* `dns` - DNS lookups for where to actually route the calls
For example, to run on a different port, you would call
AcmeEx.Router.child_spec(port: 4003)
"""
@favicon File.read!("./assets/favicon.ico")
def init(opts), do: opts |> Map.new()
@doc """
Generate a child_spec to be supervised. Available opts include:
* `adapter` - Defaults based on which version of cowboy you are running
* `port` - Defaults to `4002`
* `site` - Defaults to `http://localhost:{port}`
"""
def child_spec(opts \\ []) do
{adapter(opts),
scheme: :http,
plug: {__MODULE__, [site: site(opts), dns: dns(opts)]},
options: [port: port(opts)]}
end
@doc """
Determine the web adapter to use. It will default based on
the version of cowboy you are using.
## Examples
iex> AcmeEx.Router.adapter([])
Plug.Adapters.Cowboy2
iex> AcmeEx.Router.adapter([adapter: Plug.Adapters.Cowboy])
Plug.Adapters.Cowboy
iex> AcmeEx.Router.adapter([adapter: "Cowboy2"])
Plug.Adapters.Cowboy2
"""
def adapter(opts), do: opts[:adapter] |> resolve_adapter()
@doc """
Determine the Acme `port` to run on. This will default to 4002 if none provided.
## Examples
iex> AcmeEx.Router.port([])
4002
iex> AcmeEx.Router.port([port: 4848])
4848
"""
def port(opts), do: opts[:port] || 4002
@doc """
Determine the Acme `site` URL. You can provide this directly
when you start the app using
## Examples
iex> AcmeEx.Router.site([])
"http://localhost:4002"
iex> AcmeEx.Router.site([port: 4848])
"http://localhost:4848"
iex> AcmeEx.Router.site([site: "http://localhost:9999"])
"http://localhost:9999"
"""
def site(opts), do: opts[:site] || "http://localhost:#{port(opts)}"
@doc """
The underlying DNS lookup, this is useful for routing records somewhere else for testing
## Examples
iex> AcmeEx.Router.dns([])
nil
iex> AcmeEx.Router.dns(dns: %{"foo.bar" => fn -> "localhost:4848" end}) |> Map.keys()
["foo.bar"]
"""
def dns(opts), do: opts[:dns]
def call(%Conn{method: method, request_path: path} = conn, config) do
conn
|> read_body()
|> (fn {:ok, body, conn} ->
handle_call(conn, method, path, body, config)
end).()
end
defp handle_call(conn, _, "/", _body, _config) do
send_resp(conn, 200, "hello world")
end
defp handle_call(conn, "HEAD", "/new" <> _, _body, _config) do
Logger.info(fn -> "HEAD /new" end)
respond_body(conn, 405, "", [Header.nonce()])
end
defp handle_call(conn, "GET", "/directory", _body, config) do
Logger.info(fn -> "GET /directory" end)
respond_json(conn, 200, %{
newNonce: "#{config.site}/new-nonce",
newAccount: "#{config.site}/new-account",
newOrder: "#{config.site}/new-order",
newAuthz: "#{config.site}/new-authz",
revokeCert: "#{config.site}/revoke-cert",
keyChange: "#{config.site}/key-change"
})
end
defp handle_call(conn, "POST", "/new-account", body, _config) do
Logger.info(fn -> "POST /new-account" end)
body
|> verify_request()
|> (fn
{:error, reason} ->
respond_error(conn, "malformed", reason)
request ->
request
|> Account.client_key()
|> Account.new()
|> (&respond_json(conn, 201, &1, [Header.nonce()])).()
end).()
end
defp handle_call(conn, "POST", "/new-order", body, config) do
Logger.info(fn -> "POST /new-order" end)
body
|> verify_request()
|> create_order()
|> (fn {order, account} ->
respond_json(
conn,
201,
%{
status: order.status,
expires: Order.expires(),
identifiers: Order.identifiers(order),
authorizations: [Order.authorization(config, order, account)],
finalize: Order.finalize(config, order, account)
},
[Header.location(config, order, account), Header.nonce()]
)
end).()
end
defp handle_call(conn, "GET", "/order/" <> path, _body, config) do
path
|> Order.decode_path()
|> (fn {order, account} -> Order.to_summary(config, order, account) end).()
|> (&respond_json(conn, 200, &1)).()
end
defp handle_call(conn, "GET", "/authorizations/" <> path, _body, config) do
Logger.info(fn -> "GET /authorizations/#{path}" end)
path
|> Order.decode_path()
|> (fn {order, account} ->
respond_json(conn, 200, %{
status: order.status,
identifier: %{type: "dns", value: "localhost"},
challenges: [Order.to_challenge(config, order, account)]
})
end).()
end
defp handle_call(conn, "POST", "/challenge/http/" <> path, body, config) do
Logger.info(fn -> "POST /challenge/http/#{path}" end)
body
|> verify_order(path)
|> (fn {request, {order, account}} ->
{:ok, _pid} =
AcmeEx.Challenge.start_verify(
{order, account},
config.dns,
Account.thumbprint(request)
)
respond_json(conn, 200, Order.to_challenge(config, order, account), [
Header.nonce(),
Header.authorization(config, order, account)
])
end).()
end
defp handle_call(conn, "POST", "/finalize/" <> path, body, config) do
Logger.info(fn -> "POST /finalize/#{path}" end)
body
|> verify_order(path)
|> (fn {request, {_order, account} = id} ->
request
|> Cert.generate!(id)
|> update_cert(id)
|> (&Order.to_summary(config, &1, account)).()
|> (&respond_json(conn, 200, &1, [Header.nonce()])).()
end).()
end
defp handle_call(conn, "GET", "/cert/" <> path, _body, _config) do
Logger.info(fn -> "GET /cert/#{path}" end)
path
|> Order.decode_path()
|> (fn {order, _account} -> order.cert end).()
|> (&respond_body(conn, 200, &1)).()
end
defp handle_call(conn, "GET", "/favicon.ico", _body, _config) do
respond_body(conn, 200, @favicon, [{"content-type", "image/x-icon"}])
end
defp handle_call(conn, method, request_path, _body, _config) do
Logger.info(fn -> "#{method} #{request_path} (unknown path)" end)
respond_body(conn, 404, "Unable to resolve #{conn.request_path}")
end
defp respond_error(conn, type, reason) do
conn
|> respond_body(
403,
Jason.encode!(%{
type: "urn:ietf:params:acme:error:#{type}",
detail: reason
}),
[{"content-type", "application/json"}]
)
end
defp respond_json(conn, status, data, headers \\ []) do
conn
|> merge_resp_headers(headers)
|> respond_body(
status,
Jason.encode!(data),
[{"content-type", "application/json"}]
)
end
defp respond_body(conn, status, body, headers \\ []) do
conn
|> merge_resp_headers(headers)
|> send_resp(status, body)
end
defp verify_request(body) do
body
|> Jws.decode()
|> (fn
{:ok, request} ->
request
|> get_in([:protected, "nonce"])
|> Base.decode64!(padding: false)
|> String.to_integer()
|> Nonce.verify()
request
{:error, :empty} ->
{:error, "No request was provided, unable to proceed."}
end).()
end
defp verify_order(body, path) do
body
|> verify_request()
|> (&{&1, Order.decode_path(path)}).()
end
defp create_order(request) do
request
|> Account.client_key()
|> Account.upsert()
|> (fn account ->
request
|> Order.domains()
|> Order.new(account)
|> (&{&1, account}).()
end).()
end
defp update_cert(cert, {order, account}) do
account.id
|> Order.update(%{order | cert: cert})
|> case do
{:ok, updated_order} -> updated_order
{:error, reason} -> raise reason
end
end
defp resolve_adapter(nil) do
case Application.spec(:cowboy, :vsn) do
'1.' ++ _ -> Plug.Adapters.Cowboy
_ -> Plug.Adapters.Cowboy2
end
end
defp resolve_adapter(name) when is_binary(name) do
String.to_atom("Elixir.Plug.Adapters.#{name}")
end
defp resolve_adapter(name) when is_atom(name), do: name
end